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.tsxCopy 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),
)}
/>
)
}