Button
A motion-first button primitive. Four visual variants (primary, secondary, ghost, destructive), three sizes (sm, md, lg), optional leading and trailing icon slots, and a subtle scale-on-press tap response. Variants are typed via CVA so the prop signature stays in sync with the styling recipe.
Customize
Style
primary
md
Content
Click me
Installation
npx shadcn@latest add https://craftbits.dev/r/button.jsonUsage
import { Button } from "@craft-bits/core";
<Button>Save changes</Button>
<Button variant="secondary">Cancel</Button>
<Button variant="destructive" size="sm">Delete</Button>Render through any element via Radix Slot:
<Button asChild>
<a href="/dashboard">Open dashboard</a>
</Button>Understanding the component
- Variants via CVA.
buttonVariantsis acva()recipe that composes the base classes, the per-variant color tokens (bg-cb-accent,bg-cb-error, etc.), and the per-size sizing (h-8/h-9/h-11paired withrounded-cb-sm/md/lg). The prop signature is derived from the recipe viaVariantProps<typeof buttonVariants>. - Motion on press. The default render is a
motion.button.whileTap={TAP_SCALE}scales to 0.96 on press;transition={SPRINGS.snap}springs the value back. Whendisabledis set, the tap gesture is dropped so disabled buttons feel inert. - Focus ring on accent. Keyboard focus shows a 2px ring in
var(--cb-accent)with a 2px offset againstcb-bg— preserves contrast in both light and dark themes. asChildslot. WhenasChildis true, the button forwards itsclassName,ref, and remaining props onto a single child via RadixSlot. This branch renders a static element (no motion gestures) because Slot cannot reliably forwardmotion's gesture handlers.
Variants
<Button variant="primary">Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="destructive">Destructive</Button>Sizes are independent of variant:
<Button size="sm">Small</Button>
<Button size="md">Medium</Button>
<Button size="lg">Large</Button>With icons:
<Button icon={<PlusIcon />}>Add item</Button>
<Button iconRight={<ArrowRightIcon />}>Continue</Button>Props
| Prop | Type | Default | Description |
|---|---|---|---|
variant | 'primary' | 'secondary' | 'ghost' | 'destructive' | 'primary' | Visual style. |
size | 'sm' | 'md' | 'lg' | 'md' | Height & padding scale. |
asChild | boolean | false | Render content into the parent via Radix Slot. Disables motion gestures. |
icon | ReactNode | — | Leading icon node. Wrapped with aria-hidden. |
iconRight | ReactNode | — | Trailing icon node. Wrapped with aria-hidden. |
disabled | boolean | false | Disables the button and drops the tap gesture. |
className | string | — | Merged onto the rendered element via cn(). |
...rest | HTMLMotionProps<'button'> | — | Any other motion.button prop (onClick, aria-*, data-*, etc.). |
Accessibility
- Renders a real
<button>by default — receives focus, fires on Enter / Space, participates in form submission. - A visible
:focus-visiblering usesvar(--cb-accent)for keyboard users; mouse interaction does not show the ring. disabledsets the nativedisabledattribute (no click, removed from tab order) and drops the tap gesture so the button feels inert.- Icon slots are wrapped in an
aria-hiddenspan — provide a visible label oraria-labelso the button has an accessible name even when only an icon is rendered.
Credits
- Extracted from:
craftingattention(app/src/components/ui/Button.tsx). Generalized: project-specificvar(--color-*)tokens were replaced with thecb-*system;asChildand icon slots were added; variants were renamed (subtle→ghost,danger→destructive) to match shadcn convention.