Button
betaA button that runs an action, with tones, emphasis levels, sizes, and a loading state.
Preview
solid
soft
outline
ghost
size
icons
states
Install
1. Add the registry to
components.json, once per app. Without it, the shadcn CLI looks up@txbrendanin a public list that txbrendan/ui does not control.npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.
npx shadcn@latest add @txbrendan/theme3. 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
| Prop | Type | Default | What it does |
|---|---|---|---|
| tone | "neutral" | "brand" | "danger" | "success" | "warning" | "neutral" | Color role. |
| emphasis | "solid" | "soft" | "outline" | "ghost" | "solid" | How loud the button is. |
| size | "sm" | "md" | "lg" | "md" | Height and padding. Density scales them. |
| loading | boolean | false | Shows a spinner, keeps the width, blocks clicks, and stays focusable. |
| disabled | boolean | false | Blocks interaction and removes the button from the tab order. |
| iconOnly | boolean | false | Square button for one icon. TypeScript requires aria-label when true. |
| render | ReactElement | - | Render prop. Renders this element instead of the <button>, with the same props. Pass nativeButton={false} if it is not a <button>. |
| texture | "flat" | "dither" | "gradient" | "grain" | - | 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. |
| className | string | - | Extra classes. Later classes win, so className='rounded-none' replaces the radius. |
| shake | (element: HTMLElement | null) => void | - | 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. |
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><Button texture="dither" tone="brand">Save changes</Button><Button texture="gradient" tone="brand">Save changes</Button><Button texture="grain" 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><div className="flex gap-2">
<Button texture="dither" emphasis="ghost">Cancel</Button>
<Button texture="dither" tone="danger">Delete project</Button>
</div><div className="flex gap-2">
<Button texture="gradient" emphasis="ghost">Cancel</Button>
<Button texture="gradient" tone="danger">Delete project</Button>
</div><div className="flex gap-2">
<Button texture="grain" emphasis="ghost">Cancel</Button>
<Button texture="grain" 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>
);
}"use client";
import { useFormStatus } from "react-dom";
function SubmitButton() {
const { pending } = useFormStatus();
return (
<Button texture="dither" type="submit" tone="brand" loading={pending}>
Create account
</Button>
);
}"use client";
import { useFormStatus } from "react-dom";
function SubmitButton() {
const { pending } = useFormStatus();
return (
<Button texture="gradient" type="submit" tone="brand" loading={pending}>
Create account
</Button>
);
}"use client";
import { useFormStatus } from "react-dom";
function SubmitButton() {
const { pending } = useFormStatus();
return (
<Button texture="grain" 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>import { Trash2 } from "lucide-react";
<Button texture="dither" iconOnly aria-label="Delete row" emphasis="ghost" tone="danger">
<Trash2 />
</Button>import { Trash2 } from "lucide-react";
<Button texture="gradient" iconOnly aria-label="Delete row" emphasis="ghost" tone="danger">
<Trash2 />
</Button>import { Trash2 } from "lucide-react";
<Button texture="grain" 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>
)}
</>
);
}"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
texture="dither"
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>
)}
</>
);
}"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
texture="gradient"
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>
)}
</>
);
}"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
texture="grain"
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>