Button
A clear, responsive action with quiet secondary states.
$
pnpm dlx shadcn@latest add https://lodeui.com/r/button.jsonStyles
Icons and sizes
Feedback and states
components/examples/button-demo.tsx
"use client";
import * as React from "react";
import { ArrowRight, Check, Copy, Ellipsis, Plus } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { Button } from "@/components/ui/button";
export default function ButtonDemo() {
const [copied, setCopied] = React.useState(false);
const [uploading, setUploading] = React.useState(false);
const copy = () => {
setCopied(true);
setTimeout(() => setCopied(false), 1400);
};
const upload = () => {
setUploading(true);
setTimeout(() => setUploading(false), 1600);
};
return (
<div className="flex flex-col items-center gap-8">
<Row title="Styles">
<Button size="lg">Save changes</Button>
<Button size="lg" variant="secondary">Secondary</Button>
<Button size="lg" variant="ghost">Ghost</Button>
<Button size="lg" variant="destructive">Delete</Button>
</Row>
<Row title="Icons and sizes">
<Button variant="outline"><Plus />Add item</Button>
<Button size="lg">Continue<ArrowRight /></Button>
<Button variant="ghost" size="icon" aria-label="More"><Ellipsis /></Button>
<Button variant="secondary" size="lg">Large</Button>
</Row>
<Row title="Feedback and states">
<Button variant="outline" onClick={copy} className="min-w-28">
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={copied ? "done" : "idle"}
initial={{ opacity: 0, scale: 0.6, filter: "blur(2px)" }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, scale: 0.6, filter: "blur(2px)" }}
transition={{ type: "spring", bounce: 0.3, duration: 0.35 }}
className="inline-flex items-center gap-2"
>
{copied ? <Check /> : <Copy />}
{copied ? "Copied" : "Copy link"}
</motion.span>
</AnimatePresence>
</Button>
<Button variant="secondary" loading={uploading} onClick={upload}>Upload file</Button>
<Button variant="secondary" disabled>Disabled</Button>
</Row>
</div>
);
}
function Row({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="flex flex-col items-center gap-3">
<span className="text-sm text-muted-foreground">{title}</span>
<div className="flex flex-wrap items-center justify-center gap-3">{children}</div>
</div>
);
}When to use
- The main or secondary action in a form, dialog, card or toolbar.
- Async actions that need a loading state without the button changing width.
- Icon-only actions in dense toolbars, with an aria-label.
When not to use
- Navigating to another page: render a link styled with buttonVariants() instead.
- Picking one option from a few: use a segmented control or radio group.
- An action whose result should show in place (Saved, Failed): use the action button.
Installation
$
pnpm dlx shadcn@latest add https://lodeui.com/r/button.json1Copy the source into your project
components/ui/button.tsximport * as React from "react"; import { cn } from "@/lib/utils"; const variants = { primary: "bg-primary text-primary-foreground shadow-sm hover:bg-primary/88", secondary: "bg-secondary text-secondary-foreground hover:bg-accent", outline: "border border-input bg-transparent text-foreground hover:bg-accent", ghost: "text-muted-foreground hover:bg-accent hover:text-foreground", destructive: "bg-destructive/12 text-destructive hover:bg-destructive/18", } as const; const sizes = { sm: "h-8 gap-1.5 px-3 text-[13px]", md: "h-10 gap-2 px-4 text-sm", lg: "h-12 gap-2 px-6 text-[15px]", icon: "size-10", "icon-sm": "size-8", } as const; export type ButtonVariant = keyof typeof variants; export type ButtonSize = keyof typeof sizes; export function buttonVariants({ variant = "primary", size = "md", className, }: { variant?: ButtonVariant; size?: ButtonSize; className?: string } = {}) { return cn( "relative inline-flex shrink-0 select-none items-center justify-center whitespace-nowrap rounded-md font-medium", "transition-[background-color,color,box-shadow,transform,opacity] duration-150 ease-out active:scale-[0.97]", "outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background", "disabled:pointer-events-none disabled:opacity-40 [&_svg]:size-4 [&_svg]:shrink-0", variants[variant], sizes[size], className, ); } export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> { variant?: ButtonVariant; size?: ButtonSize; /** Shows a spinner and blocks clicks while keeping the button's width. */ loading?: boolean; } export function Button({ className, variant, size, loading = false, disabled, children, type = "button", ...props }: ButtonProps) { return ( <button type={type} data-slot="button" disabled={disabled || loading} aria-busy={loading || undefined} className={buttonVariants({ variant, size, className })} {...props} > <span className={cn("inline-flex items-center gap-[inherit]", loading && "invisible")}> {children} </span> {loading && ( <span className="absolute inset-0 grid place-items-center" aria-hidden> <Spinner /> </span> )} </button> ); } function Spinner() { return ( <svg viewBox="0 0 24 24" fill="none" className="animate-spin"> <circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" /> <path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="3" strokeLinecap="round" /> </svg> ); }
Usage
Import it from @/components/ui/button after installing.
example.tsx
import { Button } from "@/components/ui/button";
export function SaveBar({ pending, onSave }: { pending: boolean; onSave: () => void }) {
return (
<div className="flex justify-end gap-2">
<Button variant="ghost">Discard</Button>
<Button loading={pending} onClick={onSave}>Save changes</Button>
</div>
);
}API reference
Button
A native button with five visual weights, five sizes and a loading state. All other button attributes pass through.
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | "primary" | "secondary" | "outline" | "ghost" | "destructive" | "primary" | Visual weight. Use one primary per view. |
| size | "sm" | "md" | "lg" | "icon" | "icon-sm" | "md" | Height and padding. Icon sizes are square. |
| loading | boolean | false | Shows a spinner, disables the button and keeps its width so the layout doesn't jump. |
| type | "button" | "submit" | "reset" | "button" | Defaults to button so it never submits a form by accident. |
| ...props | ButtonHTMLAttributes<HTMLButtonElement> | – | onClick, disabled, aria-* and anything else a button accepts. |
buttonVariants
Returns the button class string, for links and other elements that should look like a button.
| Prop | Type | Default | Description |
|---|---|---|---|
| options | { variant?; size?; className? } | – | Same variant and size values as Button. |
Keyboard interactions
| Keys | Action |
|---|---|
| EnterorSpace | Activates the button |
| Tab | Moves focus to and from the button |
Accessibility
- Renders a real <button>, so it is focusable and announced as a button.
- Loading sets aria-busy and disables the control; the label stays in the DOM for screen readers.
- Icon-only buttons need an aria-label. The focus ring meets contrast on both themes.
Motion
- Scales to 97% while pressed over 150ms ease-out.
- Hover only changes color, so dense toolbars stay calm.
Responsive behavior
- Sizes are fixed heights (32, 40, 48px), so buttons line up in any layout.
- Text never wraps; pair long labels with a wider container or the sm size on phones.
- Use w-full on mobile for primary actions at the bottom of forms.
Performance
- No JavaScript beyond React: hover and press are CSS transitions.
- buttonVariants is a pure function you can call on the server.
Notes for AI
- Use variant="primary" once per view; secondary or ghost for the rest.
- Pass loading instead of swapping the label for a spinner yourself.
- For links, use <Link className={buttonVariants({ variant: "outline" })}>, not <Button onClick={router.push}>.
This page as Markdown, for assistants and coding agents:
/components/button/markdownThe full library index for assistants is at /llms.txt.
Lode222 doc pagesllms.txt