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.json

Usage

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

  1. Variants via CVA. buttonVariants is a cva() 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-11 paired with rounded-cb-sm/md/lg). The prop signature is derived from the recipe via VariantProps<typeof buttonVariants>.
  2. 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. When disabled is set, the tap gesture is dropped so disabled buttons feel inert.
  3. Focus ring on accent. Keyboard focus shows a 2px ring in var(--cb-accent) with a 2px offset against cb-bg — preserves contrast in both light and dark themes.
  4. asChild slot. When asChild is true, the button forwards its className, ref, and remaining props onto a single child via Radix Slot. This branch renders a static element (no motion gestures) because Slot cannot reliably forward motion'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

PropTypeDefaultDescription
variant'primary' | 'secondary' | 'ghost' | 'destructive''primary'Visual style.
size'sm' | 'md' | 'lg''md'Height & padding scale.
asChildbooleanfalseRender content into the parent via Radix Slot. Disables motion gestures.
iconReactNodeLeading icon node. Wrapped with aria-hidden.
iconRightReactNodeTrailing icon node. Wrapped with aria-hidden.
disabledbooleanfalseDisables the button and drops the tap gesture.
classNamestringMerged onto the rendered element via cn().
...restHTMLMotionProps<'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-visible ring uses var(--cb-accent) for keyboard users; mouse interaction does not show the ring.
  • disabled sets the native disabled attribute (no click, removed from tab order) and drops the tap gesture so the button feels inert.
  • Icon slots are wrapped in an aria-hidden span — provide a visible label or aria-label so 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-specific var(--color-*) tokens were replaced with the cb-* system; asChild and icon slots were added; variants were renamed (subtleghost, dangerdestructive) to match shadcn convention.