8.0 KiB
Button
(no submodules)
- button.variants — `buttonVariants` is the cva (clas…-
button.variants —
buttonVariantsis the cva (class-variance-authority) configuration that powers every<Button>render. It is split f… -
button.variants —
buttonVariantsis the cva (clas… -
button.variants —
buttonVariantsis the cva (class-variance-authority) configuration that powers every<Button>render. It is split f… -
button.variants —
buttonVariantsis the cva (clas… -
button.variants —
buttonVariantsis the cva (class-variance-authority) configuration that powers every<Button>render. It is split f… -
button.variants —
buttonVariantsis the cva (clas… -
button.variants —
buttonVariantsis the cva (class-variance-authority) configuration that powers every<Button>render. It is split f… -
button.variants —
buttonVariantsis 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— whentrue, rendersprops.childrenwith 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 underasChild). - Theme-driven variant overrides via
useThemedVariants("Button", buttonVariants). AThemePlugin.variantOverrides.Buttonmay add brand-new variants likesize: "xl"(see the type-safety trade-off inproposal.md§"Type-safety trade-off"). - Icon-slot conventions: child
<svg>elements receivepointer-events-none size-4 shrink-0so 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 fallbackreact-ui.button.iconOnly.fallbackso screen readers do not announce the empty button. prefers-reduced-motion: hover and focus transitions collapse to instant transitions undermotion-reduce.- Focus styling via
focus-visible:ring-2 focus-visible:ring-ringso keyboard users see a visible focus ring; pointer focus does not. - All native
ButtonHTMLAttributesare forwarded soonClick,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 thereact-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
hasTextContentwalker 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-noneis hardcoded into the base classes rather than driven bymotionTokens.respectReducedMotionbecause the cva-string-vs-CSS-variable boundary makes the variable approach awkward for transition shorthands. The chassis relies on Tailwind'smotion-reducevariant which honoursprefers-reduced-motionnatively.aria-labelprecedence: when the consumer suppliesaria-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, noaria-labelis set (the text itself becomes the accessible name).