component — actions

Button

Clickable button built on react-aria-components, styled with the quebi design system. Five intents and a full size scale.

  • action
  • form
  • interactive
  • cta

04 examples

Intents

Five intents. The solid primary is the one call to action on a surface; the others step down from it.

Sizes

With icon

Disabled

source

button.tsx

Copy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.

"use client"

import {
  Button as ButtonPrimitive,
  type ButtonProps as ButtonPrimitiveProps,
  composeRenderProps,
} from "react-aria-components"
import type { VariantProps } from "tailwind-variants"
import { cn, tv } from "@/lib/utils"

/**
 * Button — quebi design system
 *
 * Intents: primary (solid slate), secondary (raised ground), outline
 * (hairline-edged), ghost (no box at rest), danger (outlined in danger).
 * Sizes: xs / sm / md (default) / lg / xl, plus square icon-only (sq-*).
 *
 * Flat: the control radius (square inside `quebi-editorial`) unless
 * `isCircle`, a 1px border on every intent (transparent where the intent has
 * no edge, so every intent is the same height), and no shadow — a button sits
 * in the page flow, and depth there comes from rules. Hover changes the fill
 * and nothing else.
 */
export const buttonStyles = tv({
  base: [
    "inline-flex items-center justify-center gap-2",
    "font-(family-name:--q-font-control) font-medium whitespace-nowrap select-none cursor-pointer",
    "border-(length:--q-border-control) border-solid",
    // Named properties, not `transition-all`: `all` would also animate layout
    // and the focus ring. These are what the intents and `disabled:`/`pending:`
    // actually move.
    // A theme with a hard shadow (`--q-shadow-*`) and a `--q-press` makes the
    // button travel into its shadow under the pointer; in the default theme
    // and in `quebi-editorial` both are zero, so only the colours move.
    "transition-[background-color,border-color,color,opacity,translate,box-shadow] duration-150 ease-out",
    "hover:translate-(--q-press) pressed:translate-(--q-press) hover:shadow-none pressed:shadow-none disabled:translate-0",
    "outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-quebi-focus focus-visible:ring-offset-2 focus-visible:ring-offset-quebi-bg",
    "disabled:opacity-45 disabled:cursor-not-allowed",
    "pending:opacity-70 pending:cursor-wait",
    // react-aria slot conventions — icons & loaders inherit current color
    "*:data-[slot=icon]:shrink-0 *:data-[slot=icon]:self-center",
    "*:data-[slot=loader]:shrink-0 *:data-[slot=loader]:self-center",
  ],
  variants: {
    intent: {
      // The design's solid button: slate in Light, light in Dark. Hover steps
      // the fill toward the body colour; the label stays above 4.5:1 at both
      // ends.
      primary:
        "bg-quebi-action border-quebi-action text-quebi-on-action shadow-(--q-shadow-action) hover:bg-quebi-action-hover hover:border-quebi-action-hover",
      secondary:
        "bg-quebi-raised border-transparent text-quebi-fg shadow-(--q-shadow-control) hover:bg-quebi-pressed",
      // The design's outline button, drawn by the app-line hairline every
      // control edge uses.
      outline:
        // The control ground: the card ground while fields are boxed,
        // transparent once a theme underlines them — an outline button follows.
        "bg-(--q-field-bg) border-quebi-hairline text-quebi-fg shadow-(--q-shadow-control) hover:bg-quebi-raised",
      ghost:
        "bg-transparent border-transparent text-quebi-fg-muted hover:bg-quebi-raised hover:text-quebi-fg",
      // Outline-shaped, edged and labelled in `danger`: a destructive button
      // says what it destroys, it does not shout. Hover is the raised ground,
      // like the outline button, so there is no second red to keep in contrast.
      danger:
        "bg-(--q-field-bg) border-quebi-danger text-quebi-danger shadow-(--q-shadow-control) hover:bg-quebi-raised",
    },
    size: {
      xs: ["text-xs px-2.5 py-1.5", "*:data-[slot=icon]:size-3 *:data-[slot=loader]:size-3"],
      sm: ["text-sm px-3.5 py-2", "*:data-[slot=icon]:size-3.5 *:data-[slot=loader]:size-3.5"],
      // The design's button: 14px Inter 500, 20px sides. Its 13px vertical
      // padding is on a line-height of 1; here the line box is 20px, so 12px
      // padding gives the same optical weight with room for descenders.
      md: ["text-sm px-5 py-3", "*:data-[slot=icon]:size-4 *:data-[slot=loader]:size-4"],
      lg: ["text-base px-6 py-3.5", "*:data-[slot=icon]:size-5 *:data-[slot=loader]:size-5"],
      xl: ["text-lg px-8 py-4", "*:data-[slot=icon]:size-6 *:data-[slot=loader]:size-6"],
      // Square / icon-only. `size-*` is border-box, so a square matches its
      // text-sized sibling only if the number includes the 1px border on each
      // side: xs is line-height 16 + py-1.5 12 + 2 = 30px, sm 20 + 16 + 2 = 38,
      // md 20 + 24 + 2 = 46, lg 24 + 28 + 2 = 54.
      "sq-xs": "size-7.5 p-0 *:data-[slot=icon]:size-3.5",
      "sq-sm": "size-9.5 p-0 *:data-[slot=icon]:size-4",
      "sq-md": "size-11.5 p-0 *:data-[slot=icon]:size-5",
      "sq-lg": "size-13.5 p-0 *:data-[slot=icon]:size-6",
    },
    // The radius lives here, not in `base`, so `rounded-full` never has to win
    // a tailwind-merge against a base radius: mutually exclusive branches never
    // rely on the merge at all.
    isCircle: {
      true: "rounded-full",
      false: "rounded-(--q-radius-control)",
    },
  },
  defaultVariants: {
    intent: "primary",
    size: "md",
    isCircle: false,
  },
})

export interface ButtonProps extends ButtonPrimitiveProps, VariantProps<typeof buttonStyles> {
  ref?: React.Ref<HTMLButtonElement>
}

export function Button({ className, intent, size, isCircle, ref, ...props }: ButtonProps) {
  return (
    <ButtonPrimitive
      ref={ref}
      {...props}
      className={composeRenderProps(className, (resolved) =>
        cn(buttonStyles({ intent, size, isCircle }), resolved),
      )}
    />
  )
}