Button
registry/pouf/Button.tsx
import { cva, cx } from 'class-variance-authority'
import {
forwardRef,
type ButtonHTMLAttributes,
type MouseEventHandler,
type ReactNode,
} from 'react'
import { toneClass, type Tone } from './tone'
interface ButtonProps
extends Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
'children' | 'className' | 'style' | 'onClick' | 'type' | 'disabled'
> {
children: ReactNode
onClick?: MouseEventHandler<HTMLButtonElement>
tone?: Tone
size?: 'sm' | 'md' | 'lg'
/** quiet: no cushion until hover — for tertiary actions that shouldn't
* compete with the primary cushion on the same row. */
variant?: 'solid' | 'quiet'
block?: boolean
disabled?: boolean
/** Shows a spinner and blocks the click. A submit that fires twice because
* it looked idle is a real double-write, so pending state is not
* decoration here — it's a safety property. */
loading?: boolean
type?: 'button' | 'submit'
/** Required when the label alone isn't descriptive (icon-only buttons). */
label?: string
}
/* 'pouf-btn' rides along as an unstyled MARKER class: it carries no rules of
* its own anymore, but container selectors (e.g. the dialog head's
* `> .pouf-btn` spacing override) still address buttons through it.
*
* Same-property utilities don't cascade like the old selectors did, so
* solid/quiet each own their background and shadow outright. A disabled
* cushion must read as pressed-flat, not merely faded: the affordance is the
* depth, so removing the depth is the real signal — solid goes pressed,
* quiet (already flat) goes bare. */
const button = cva(
[
'pouf-btn relative items-center justify-center gap-(--s2) font-pouf font-black leading-none',
/* Colour lives on the variants, not here: `solid` sits on a pastel accent
* and must follow --on-accent (which stays dark in dark mode), while
* `quiet` is transparent on the page and follows --ink. In light mode both
* resolve to the same value, so this is a no-op for the goldens. */
'border-none cursor-pointer [touch-action:manipulation]',
'[transition:box-shadow_120ms_ease,transform_120ms_ease]',
'enabled:active:[transform:translateY(2px)] enabled:active:cushion-control-active',
'disabled:cursor-not-allowed disabled:opacity-50',
],
{
variants: {
size: {
/* Centre labels in the complete cushion silhouette. The old 6px
* top/bottom bias placed type visibly high once the floor lip and
* drop-shadow were read as part of the control. */
sm: 'text-[13px] px-4 py-[9px] min-h-[38px] rounded-[14px]',
md: 'text-[15px] px-[26px] py-[14px] min-h-12 rounded-control',
lg: 'text-[17px] px-8 py-[18px] min-h-14 rounded-control',
},
variant: {
solid:
'text-[var(--on-accent)] bg-[var(--tone,var(--purple))] cushion-control disabled:cushion-control-active disabled:[transform:translateY(2px)]',
quiet:
'text-[var(--quiet-ink,var(--ink))] bg-transparent [box-shadow:inset_0_0_0_2px_rgba(201,168,255,0.55)] enabled:hover:bg-bg enabled:hover:text-ink enabled:hover:cushion-field disabled:[box-shadow:none]',
},
block: {
true: 'flex w-full',
false: 'inline-flex',
},
shape: {
label: '',
icon: '',
},
},
compoundVariants: [
{ size: 'sm', shape: 'icon', className: 'w-[38px] px-0' },
{ size: 'md', shape: 'icon', className: 'w-12 px-0' },
{ size: 'lg', shape: 'icon', className: 'w-14 px-0' },
],
defaultVariants: { size: 'md', variant: 'solid', block: false, shape: 'label' },
},
)
/** The button look as a class string, exported because Segmented, Tabs,
* ToggleGroup, and BottomNav's menu compose literal buttons — sharing the
* builder means a toggle can never drift from the button it imitates. */
export function buttonClasses(
opts: {
tone?: Tone
size?: 'sm' | 'md' | 'lg'
variant?: 'solid' | 'quiet'
block?: boolean
shape?: 'label' | 'icon'
} = {},
): string {
const { tone = 'purple', size, variant, block, shape } = opts
return cx(button({ size, variant, block, shape }), toneClass(tone))
}
function LoadingSpinner() {
return (
<span
className="size-[15px] rounded-[50%] border-[3px] border-solid border-[color-mix(in_srgb,currentColor_24%,transparent)] border-t-current [animation:pouf-spin_620ms_linear_infinite]"
aria-hidden="true"
/>
)
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{
children,
onClick,
tone = 'purple',
size = 'md',
variant = 'solid',
block,
disabled,
loading,
type = 'button',
label,
...nativeProps
},
ref,
) {
return (
<button
ref={ref}
{...nativeProps}
type={type}
className={buttonClasses({ tone, size, variant, block })}
onClick={onClick}
disabled={disabled || loading}
aria-busy={loading || undefined}
aria-label={label}
>
{loading && <LoadingSpinner />}
{children}
</button>
)
})
interface IconButtonProps
extends Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
'children' | 'className' | 'style' | 'onClick' | 'type' | 'disabled' | 'aria-label'
> {
icon: ReactNode
/** Icon-only actions must always expose their meaning to assistive tech. */
label: string
onClick?: MouseEventHandler<HTMLButtonElement>
tone?: Tone
size?: 'sm' | 'md' | 'lg'
variant?: 'solid' | 'quiet'
disabled?: boolean
loading?: boolean
type?: 'button' | 'submit'
}
/** A square, labelled icon action. The required `label` prevents the common
* accessible-name omission, while the shape stays aligned with Button sizes. */
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(function IconButton(
{
icon,
label,
onClick,
tone = 'purple',
size = 'md',
variant = 'quiet',
disabled,
loading,
type = 'button',
...nativeProps
},
ref,
) {
return (
<button
ref={ref}
{...nativeProps}
type={type}
className={buttonClasses({ tone, size, variant, shape: 'icon' })}
onClick={onClick}
disabled={disabled || loading}
aria-busy={loading || undefined}
aria-label={label}
title={nativeProps.title ?? label}
>
{loading ? <LoadingSpinner /> : <span aria-hidden="true">{icon}</span>}
</button>
)
})
No selection tags yet — empty means no evidence, not lack of support.
DependenciesBundle weight · light
class-variance-authority