@cf/ui

@cf/button

Button

The clickable action primitive, in six variants and seven sizes, that every other control here builds its buttons from.

Button is Base UI's Button with shadcn's variants on top. Every size carries touch-manipulation, which stops a double-tap zooming the page and costs nothing elsewhere.

What it doesn't do is grow small buttons to 44px. I tried that and it made dense rows worse, so isolated icon buttons opt in (the chat header uses size-11 md:size-8) and dense rows get real height below md from their own layout. It still uses forwardRef, which I haven't moved to ref-as-a-prop yet.

import { Button } from "@/components/ui/button";
import { TrashIcon } from "@phosphor-icons/react";
 
<Button aria-label="Delete note" size="icon-sm" variant="ghost">
  <TrashIcon data-icon />
</Button>

Props

PropTypeDefault
variantdefault | secondary | outline | ghost | link | destructivedefault
sizexs | sm | default | lg | icon | icon-sm | icon-xsdefault

Rules

The same rules ship in DESIGN.md and the MCP server, for the agents building with this.

  • must

    Use variant="destructive" for an action that deletes or can't be undone, rather than restyling default with a red class.

    The destructive variant is the one signal the notes UI, menus and alert dialogs all share for danger.

    destructive-variant

  • must

    Give every size="icon", size="icon-sm" or size="icon-xs" button an accessible name, either aria-label or a <span className="sr-only"> child.

    icon-only-needs-name

  • should

    Put data-icon (or data-icon="inline-start") on a child icon so the button sizes it to size-4, instead of sizing the icon with its own classes.

    The button styles icons through [&_[data-icon]], so an unmarked icon keeps Phosphor's default 1em and drifts from its neighbours.

    mark-icons-with-data-icon

  • should

    Give an isolated small button a 44px tap target below md yourself, with touch-target or className="size-11 md:size-8"; don't expect the button to grow on its own.

    Hit-area expansion is left out on purpose, because in tight rows like the gap-0.5 editor menus neighbours would steal each other's edges.

    opt-in-tap-target

Install

bunx shadcn@latest add @cf/button

Needs the @cf registry in your components.json once. The theme and any sibling components come along automatically.

Source

button.tsxShow
"use client";
 
import { Button as ButtonPrimitive } from "@base-ui/react/button";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "cn";
import * as React from "react";
 
/**
 * `touch-manipulation` on every button: it is what stops a double-tap zooming
 * the page, and it costs nothing anywhere else.
 *
 * Hit-area expansion is deliberately *not* here. Growing every button to 44px
 * with an overlay would have neighbours stealing each other's edges wherever
 * buttons sit close together (the editor menus are `gap-0.5`), which trades one
 * mis-tap for another. Isolated small controls opt in with `touch-target`
 * instead; dense rows get real height below `md`.
 */
const buttonVariants = cva(
  "inline-flex touch-manipulation items-center justify-center whitespace-nowrap rounded-md font-medium text-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_[data-icon]]:size-4 [&_[data-icon]]:shrink-0",
  {
    defaultVariants: { size: "default", variant: "default" },
    variants: {
      size: {
        default: "h-9 px-4 py-2",
        icon: "h-9 w-9",
        "icon-sm": "size-8",
        "icon-xs": "size-6",
        lg: "h-10 rounded-md px-8",
        sm: "h-8 rounded-md px-3 text-xs",
        xs: "h-6 rounded-md px-2 text-xs",
      },
      variant: {
        default:
          "bg-primary text-primary-foreground shadow hover:bg-primary/90",
        destructive:
          "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
        ghost: "hover:bg-accent hover:text-accent-foreground",
        link: "text-primary underline-offset-4 hover:underline",
        outline:
          "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
        secondary:
          "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
      },
    },
  }
);
 
type ButtonProps = React.ComponentProps<typeof ButtonPrimitive> &
  VariantProps<typeof buttonVariants>;
 
const Button = React.forwardRef<HTMLElement, ButtonProps>(
  ({ className, size, variant, ...props }, ref) => (
    <ButtonPrimitive
      className={cn(buttonVariants({ className, size, variant }))}
      ref={ref}
      {...props}
    />
  )
);
Button.displayName = "Button";
 
export { Button, type ButtonProps, buttonVariants };