Skip to content
txbrendan/ui

Button

beta

A button that runs an action, with tones, emphasis levels, sizes, and a loading state.

Preview

Texture

solid

soft

outline

ghost

size

icons

states

Install

  1. 1. Add the registry to components.json, once per app. Without it, the shadcn CLI looks up @txbrendan in a public list that txbrendan/ui does not control.

    npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"
  2. 2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.

    npx shadcn@latest add @txbrendan/theme
  3. 3. Add the component.

    npx shadcn@latest add @txbrendan/button

Use it for

  • Running an action on the current page: save, submit, open a dialog, delete.
  • Use tone to say what kind of action it is and emphasis to say how important it is.
  • Use one solid brand button per view for the main action. Use soft, outline, or ghost for the rest.
  • Use loading while an action is running. It keeps the width and blocks repeat clicks.
  • Change the words to show a new state in place, like Save to Saved. The button eases to the new width while the words crossfade. Compose changing words and icons with MotionSwap for one coordinated transition.
  • Words too long for the space end in an ellipsis, beside an icon too. In a flex row, give the box that holds the button min-w-0. In a row that does not wrap, also add className="min-w-0 shrink" to the button. Words inside your own element, like a span or a fragment, are yours to cut. A link styled with buttonVariants() does not cut its words.
  • When a press cannot go ahead, answer on the button itself: call shake() on it, and change its words to what is missing, like "Agree to First aid consent first". The button stays where it was pressed.

Avoid it for

  • Going to another page. Use a link styled with buttonVariants() so it stays a link. In a server file, import it from button-variants, since Button runs in the browser.
  • Turning a setting on and off. Use a switch or a toggle instead.
  • Disabling a submit button to hide form errors. Keep it enabled and show the errors.
  • Showing why a press failed in a new line above the button. It pushes the button out from under the pointer. Say it on the button, or beside or below it.
  • Using tone='danger' for anything that is not destructive.

Props

  • tone

    Color role.

    "neutral" | "brand" | "danger" | "success" | "warning" · default "neutral"

  • emphasis

    How loud the button is.

    "solid" | "soft" | "outline" | "ghost" · default "solid"

  • size

    Height and padding. Density scales them.

    "sm" | "md" | "lg" · default "md"

  • loading

    Shows a spinner, keeps the width, blocks clicks, and stays focusable.

    boolean · default false

  • disabled

    Blocks interaction and removes the button from the tab order.

    boolean · default false

  • iconOnly

    Square button for one icon. TypeScript requires aria-label when true.

    boolean · default false

  • render

    Render prop. Renders this element instead of the <button>, with the same props. Pass nativeButton={false} if it is not a <button>.

    ReactElement

  • texture

    Adds dots, a soft shine, or grain to the lower half, and a glow on dark pages, on solid and outline. Hover clears the shine and grain. Soft and ghost stay flat. buttonVariants on a link stays flat. Left out, the button follows the Texture around it.

    "flat" | "dither" | "gradient" | "grain"

  • className

    Extra classes. Later classes win, so className='rounded-none' replaces the radius.

    string

  • shake

    Shakes a button once, side to side, to say a press could not go ahead. It ends where it started, and does nothing when motion is off or reduced.

    (element: HTMLElement | null) => void

Accessibility

  • Renders a native <button type="button">, so Enter and Space work and screen readers announce a button.
  • loading sets aria-disabled and aria-busy and keeps focus on the button, so keyboard users do not lose their place.
  • iconOnly requires aria-label at the type level. The label names the action, like "Delete row", not the icon.
  • The focus ring uses --ring, which the theme tests hold to 3:1 against the page and cards.
  • Press scale follows the --motion-* tokens, and reduced motion turns it off. The spinner turns at a fixed speed.
  • Words that change are read as the button's new name. The old words are hidden from screen readers while they fade. A shake says nothing, so pair it with a message that is read out, like role="alert".
  • The label transition and shake follow the --motion-* tokens. Reduced motion lands the new width at once, keeps only the words' fade, and skips the shake.

Examples

Main action

<Button tone="brand">Save changes</Button>

Destructive action with a quieter cancel

<div className="flex gap-2">
  <Button emphasis="ghost">Cancel</Button>
  <Button tone="danger">Delete project</Button>
</div>

Loading while a form submits

"use client";
import { useFormStatus } from "react-dom";

function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <Button type="submit" tone="brand" loading={pending}>
      Create account
    </Button>
  );
}

Icon-only

import { Trash2 } from "lucide-react";

<Button iconOnly aria-label="Delete row" emphasis="ghost" tone="danger">
  <Trash2 />
</Button>

A press that cannot go ahead says why on the button

"use client";
import { useRef, useState } from "react";
import { Button, shake } from "@/components/ui/button";

function ContinueButton({ ready, onContinue }: { ready: boolean; onContinue: () => void }) {
  const [asking, setAsking] = useState(false);
  const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
  return (
    <>
      <Button
        tone={asking ? "danger" : "brand"}
        emphasis={asking ? "soft" : "solid"}
        onClick={(event) => {
          if (ready) return onContinue();
          shake(event.currentTarget);
          setAsking(true);
          clearTimeout(timer.current);
          timer.current = setTimeout(() => setAsking(false), 3200);
        }}
      >
        {asking ? "Fill in your email first" : "Continue"}
      </Button>
      {asking && (
        <span role="alert" className="sr-only">
          Fill in your email first.
        </span>
      )}
    </>
  );
}

A link that looks like a button

import Link from "next/link";
import { buttonVariants } from "@/components/ui/button-variants";

<Link href="/pricing" className={buttonVariants({ tone: "brand", emphasis: "soft" })}>
  See pricing
</Link>

Grain on the lower half, cleared on hover

<Button tone="brand" texture="grain">Save changes</Button>