Files
skills/docs/ui/react/react-ui/src/components/button/button.md
2026-05-29 08:20:56 +01:00

8.0 KiB

Button

(no submodules)

- button.variants — `buttonVariants` is the cva (clas…
  • button.variants — buttonVariants is the cva (class-variance-authority) configuration that powers every <Button> render. It is split f…

  • button.variants — buttonVariants is the cva (clas…

  • button.variants — buttonVariants is the cva (class-variance-authority) configuration that powers every <Button> render. It is split f…

  • button.variants — buttonVariants is the cva (clas…

  • button.variants — buttonVariants is the cva (class-variance-authority) configuration that powers every <Button> render. It is split f…

  • button.variants — buttonVariants is the cva (clas…

  • button.variants — buttonVariants is the cva (class-variance-authority) configuration that powers every <Button> render. It is split f…

  • button.variants — buttonVariants is the cva (class-variance-authority) configuration that powers every <Button> render. It is split f…

Purpose

<Button> is the chassis primary action surface for @suaveplan/react-ui. It renders a native <button> (or polymorphically a child element via Radix <Slot> when asChild is set), threads its variant resolution through useThemedVariants("Button") so an active ThemePlugin may extend or replace any variant, and forwards refs to the underlying DOM node so consumer code (focus management, animation libraries, automated testing) can attach to the button directly.

The component deliberately does NOT carry an isLoading or loading prop. Per the upstream shadcn convention adopted across @suaveplan/react-ui, loading semantics are caller-driven: consumers compose <Spinner /> + disabled + data-icon="inline-start" next to the action label rather than letting the button library guess what "loading" means. This keeps <Button> a pure styling + a11y surface and lets the caller own the orchestration.

Features

  • Six variants (default, secondary, destructive, outline, ghost, link) and four sizes (sm, default, lg, icon) shipped in the base cva config.
  • asChild — when true, renders props.children with the button's classes / refs / props merged in via Radix <Slot>. The underlying child MUST be a single React element.
  • Refs forwarded to the underlying <button> (or to the merged child element under asChild).
  • Theme-driven variant overrides via useThemedVariants("Button", buttonVariants). A ThemePlugin.variantOverrides.Button may add brand-new variants like size: "xl" (see the type-safety trade-off in proposal.md §"Type-safety trade-off").
  • Icon-slot conventions: child <svg> elements receive pointer-events-none size-4 shrink-0 so consumers may drop a Lucide icon next to text without sizing boilerplate.
  • Accessible-name fallback: when the rendered button has no text children AND the consumer has not supplied aria-label, the chassis fills in the translated fallback react-ui.button.iconOnly.fallback so screen readers do not announce the empty button.
  • prefers-reduced-motion: hover and focus transitions collapse to instant transitions under motion-reduce.
  • Focus styling via focus-visible:ring-2 focus-visible:ring-ring so keyboard users see a visible focus ring; pointer focus does not.
  • All native ButtonHTMLAttributes are forwarded so onClick, disabled, form, formAction, etc. work as expected.

Usage: Basic Example

import { Button } from "@suaveplan/react-ui";

export function SaveAction() {
    return (
        <Button onClick={() => console.warn("save clicked")}>
            Save changes
        </Button>
    );
}

Usage: Advanced Example

import { Button, useThemedVariants } from "@suaveplan/react-ui";
import { ChevronRightIcon, MailIcon } from "lucide-react";

export function ComposedActions() {
    return (
        <div className="flex gap-2">
            <Button variant="outline" size="sm">
                <MailIcon data-icon="inline-start" />
                Email me
            </Button>

            <Button variant="destructive" size="lg" disabled>
                Delete account
            </Button>

            <Button asChild variant="link">
                <a href="/settings">Account settings</a>
            </Button>

            <Button
                variant="ghost"
                size="icon"
                aria-label="Open submenu"
                onClick={(event) => event.preventDefault()}
            >
                <ChevronRightIcon />
            </Button>
        </div>
    );
}

API Reference

<Button>

forwardRef<HTMLButtonElement, ButtonProps> — renders a <button type={type}> (default "button") when asChild is unset, or merges into props.children via Radix <Slot> when asChild === true.

ButtonProps

Prop Type Default Description
variant "default" | "secondary" | "destructive" | "outline" | "ghost" | "link" "default" Visual variant. Layered with useThemedVariants("Button") so themes may extend.
size "sm" | "default" | "lg" | "icon" "default" Padding / height variant. "icon" renders a square size-9 button intended for icon-only content.
asChild boolean false When true, renders props.children via Radix <Slot> with the button's classes + refs merged in.
accessibleNameKey TranslationKey "react-ui.button.iconOnly.fallback" Translation key used as the fallback aria-label when the button has no text children AND no explicit aria-label.
type "button" | "submit" | "reset" "button" Native button type. Suppressed when asChild is true (consumer's element owns its type).
all native ButtonHTMLAttributes<HTMLButtonElement> native native Forwarded as-is.

buttonVariants

The bare cva resolver. Exported so external consumers (@suaveplan/react-ui-theme-*, ejected source under npx shadcn@latest add @suaveplan/button) can re-use the variants directly without going through useThemedVariants.

Errors

  • useTranslation("react-ui") is called unconditionally inside the component. When the surrounding tree lacks a <TranslationProvider> (or <ReactUiProvider>), the hook returns its identity-fallback shape and the component renders without throwing — every label resolves to the literal key. This matches the react-i18n "no-throw in production" contract.

Implementation Notes

  • The variants module is intentionally separate (button.variants.ts) so an ejected source copy keeps the cva config in a dedicated file, mirroring the upstream shadcn convention. The chassis component imports it; tree-shaking keeps the footprint identical.
  • useThemedVariants("Button", buttonVariants) is called unconditionally so the hook order is stable across renders. The hook's memoisation key is (themeName, "Button") so a re-render with the same theme returns the same resolver reference.
  • The hasTextContent walker is single-level (no recursion) — if a consumer wraps text inside a deeper element tree (<Button><span><strong>label</strong></span></Button>), the chassis will still inject the icon-only fallback because it cannot see through <span>. This is by design: deeply-nested text-bearing elements should declare their own accessible name on the inner element.
  • motion-reduce:transition-none is hardcoded into the base classes rather than driven by motionTokens.respectReducedMotion because the cva-string-vs-CSS-variable boundary makes the variable approach awkward for transition shorthands. The chassis relies on Tailwind's motion-reduce variant which honours prefers-reduced-motion natively.
  • aria-label precedence: when the consumer supplies aria-label="…", it is preserved verbatim on the rendered element. When omitted AND the children carry no plain text, the icon-only fallback is filled in. When omitted AND text children are present, no aria-label is set (the text itself becomes the accessible name).