import { PlusIcon } from "lucide-react" ;
import { Button } from "@/components/ui/button" ;
export default function ButtonDemo () {
return (
< div className = "flex flex-wrap items-center gap-2" >
< Button >
< PlusIcon /> New project
</ Button >
< Button variant = "outline" >Cancel</ Button >
</ div >
);
}
CLI Manual
$ pnpm dlx shadcn@latest add @desyne/button
The CLI installs dependencies and any other components this one uses.
import { Button } from "@/components/ui/button" ;
< Button onPress = {() => save ()}>Save changes</ Button >
Use onPress, not onClick
React Aria normalizes mouse, touch, pen and keyboard input into a single
onPress event. It fires once per press, ignores scroll gestures on touch
devices and never leaves a "sticky" hover state behind. onClick still
works, but you lose those guarantees.
Use a button to perform an action: submit a form, open a dialog, start a process.
Use a link to navigate. If it changes the URL, it should be a link — style it with buttonVariants if it needs to look like a button.
Use a toggle button for an on/off state that persists, like bold or mute.
Keep one solid button per view for the primary action; use outline, soft or ghost for everything else.
A button is a single element. Icons, spinners and keyboard hints are plain children that the button sizes and spaces for you.
< Button >
< Icon /> { /* optional leading icon, auto-sized */ }
Label
< Kbd /> { /* optional trailing hint or icon */ }
</ Button >
Part Renders Notes Button<button>Root. Carries data-slot="button" and all state attributes. Icon <svg>Sized by the button (size-4, or size-3/size-3.5 for small sizes) unless you set a size-* class. Spinner <svg>Injected before the label while isPending is true.
solid for the primary action, soft and outline for secondary actions, dashed for "add" affordances, ghost for toolbars and dense UIs, and link for inline actions.
Solid Soft Outline Dashed Ghost Link
import { Button } from "@/components/ui/button" ;
export default function ButtonVariants () {
return (
< div className = "flex flex-wrap items-center gap-2" >
< Button variant = "solid" >Solid</ Button >
< Button variant = "soft" >Soft</ Button >
< Button variant = "outline" >Outline</ Button >
< Button variant = "dashed" >Dashed</ Button >
< Button variant = "ghost" >Ghost</ Button >
< Button variant = "link" >Link</ Button >
</ div >
);
}
Every variant works with every color. outline, dashed and ghost default to neutral; the rest default to primary. Use brand for accent actions, and danger only for destructive ones.
solid soft outline ghost primary primary primary primary primary
brand brand brand brand brand
neutral neutral neutral neutral neutral
danger danger danger danger danger
success success success success success
warning warning warning warning warning
info info info info info
import { Button } from "@/components/ui/button" ;
const colors = [
"primary" ,
"brand" ,
"neutral" ,
"danger" ,
"success" ,
"warning" ,
"info" ,
] as const ;
const variants = [ "solid" , "soft" , "outline" , "ghost" ] as const ;
export default function ButtonColors () {
return (
< div className = "grid grid-cols-[auto_repeat(4,auto)] items-center gap-2 text-xs" >
< span />
{variants. map (( v ) => (
< span key = {v} className = "text-center text-muted-foreground capitalize" >
{v}
</ span >
))}
{colors. map (( color ) => (
< div key = {color} className = "contents" >
< span className = "pr-2 text-muted-foreground capitalize" >{color}</ span >
{variants. map (( variant ) => (
< Button
key = {variant}
variant = {variant}
color = {color}
size = "sm"
className = "capitalize"
>
{color}
</ Button >
))}
</ div >
))}
</ div >
);
}
md (32px) is the default. xs and sm suit tables and toolbars, lg suits marketing pages and touch-first layouts.
Extra small Small Medium Large
import { Button } from "@/components/ui/button" ;
export default function ButtonSizes () {
return (
< div className = "flex flex-wrap items-center gap-2" >
< Button size = "xs" >Extra small</ Button >
< Button size = "sm" >Small</ Button >
< Button size = "md" >Medium</ Button >
< Button size = "lg" >Large</ Button >
</ div >
);
}
Buttons are inline by default. Add w-full for stacked actions in cards, forms and mobile layouts.
import { Button } from "@/components/ui/button" ;
export default function ButtonBlock () {
return (
< div className = "flex w-full max-w-xs flex-col gap-2" >
< Button className = "w-full" >Continue</ Button >
< Button variant = "outline" className = "w-full" >
Back
</ Button >
</ div >
);
}
Icons are sized and spaced automatically. Place them before the label for actions ("Add", "Upload") and after it for direction ("Next", external links).
Login with email ExportContinue
import { ArrowRightIcon, DownloadIcon, MailIcon } from "lucide-react" ;
import { Button } from "@/components/ui/button" ;
export default function ButtonWithIcon () {
return (
< div className = "flex flex-wrap items-center gap-2" >
< Button >
< MailIcon /> Login with email
</ Button >
< Button variant = "outline" >
< DownloadIcon /> Export
</ Button >
< Button variant = "soft" >
Continue < ArrowRightIcon />
</ Button >
</ div >
);
}
Use the icon-xs, icon-sm, icon and icon-lg sizes for square buttons. They have no visible text, so aria-label is required.
import { HeartIcon, SettingsIcon, Trash2Icon } from "lucide-react" ;
import { Button } from "@/components/ui/button" ;
export default function ButtonIcon () {
return (
< div className = "flex flex-wrap items-center gap-2" >
< Button size = "icon-sm" variant = "outline" aria-label = "Settings" >
< SettingsIcon />
</ Button >
< Button size = "icon" variant = "soft" aria-label = "Like" >
< HeartIcon />
</ Button >
< Button size = "icon-lg" variant = "solid" color = "danger" aria-label = "Delete" >
< Trash2Icon />
</ Button >
< Button
size = "icon"
variant = "outline"
className = "rounded-full"
aria-label = "Settings"
>
< SettingsIcon />
</ Button >
</ div >
);
}
Pair icon-only buttons with a Tooltip so sighted mouse users get the same label screen readers announce.
import { CopyIcon, PencilIcon, Trash2Icon } from "lucide-react" ;
import { Button } from "@/components/ui/button" ;
import { Tooltip, TooltipTrigger } from "@/components/ui/tooltip" ;
const actions = [
{ label: "Edit" , icon: PencilIcon },
{ label: "Duplicate" , icon: CopyIcon },
{ label: "Delete" , icon: Trash2Icon, color: "danger" as const },
];
export default function ButtonTooltip () {
return (
< div className = "flex items-center gap-1" >
{actions. map (( a ) => (
< TooltipTrigger key = {a.label}>
< Button
variant = "ghost"
size = "icon"
color = {a.color}
aria-label = {a.label}
>
< a.icon />
</ Button >
< Tooltip >{a.label}</ Tooltip >
</ TooltipTrigger >
))}
</ div >
);
}
Put a Kbd inside the button to advertise its shortcut. The button doesn't bind the key for you — register it with your own hotkey handler.
import { SearchIcon } from "lucide-react" ;
import { Button } from "@/components/ui/button" ;
import { Kbd, KbdGroup } from "@/components/ui/kbd" ;
export default function ButtonWithKbd () {
return (
< div className = "flex flex-wrap items-center gap-2" >
< Button
variant = "outline"
className = "w-56 justify-start text-muted-foreground"
>
< SearchIcon /> Search…
< KbdGroup className = "ml-auto" >
< Kbd size = "sm" >⌘</ Kbd >
< Kbd size = "sm" >K</ Kbd >
</ KbdGroup >
</ Button >
< Button >
Publish
< Kbd size = "sm" className = "border-white/20 bg-white/10 text-inherit" >
⌘↵
</ Kbd >
</ Button >
</ div >
);
}
Wrap related buttons in a React Aria Group and remove the inner radii to attach them. The group gives assistive tech a single labelled region.
import {
AlignCenterIcon,
AlignLeftIcon,
AlignRightIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react" ;
import { Group } from "react-aria-components" ;
import { Button } from "@/components/ui/button" ;
/** Attach buttons by removing inner radii and collapsing shared borders. */
const attached =
"flex *:rounded-none *:first:rounded-l-md *:last:rounded-r-md *:not-first:-ml-px *:data-focus-visible:z-10" ;
export default function ButtonGroupDemo () {
return (
< div className = "flex flex-wrap items-center gap-4" >
< Group aria-label = "Pagination" className = {attached}>
< Button variant = "outline" size = "icon" aria-label = "Previous" >
< ChevronLeftIcon />
</ Button >
< Button variant = "outline" >Page 2 of 10</ Button >
< Button variant = "outline" size = "icon" aria-label = "Next" >
< ChevronRightIcon />
</ Button >
</ Group >
< Group aria-label = "Text alignment" className = {attached}>
< Button variant = "outline" size = "icon" aria-label = "Align left" >
< AlignLeftIcon />
</ Button >
< Button variant = "outline" size = "icon" aria-label = "Align center" >
< AlignCenterIcon />
</ Button >
< Button variant = "outline" size = "icon" aria-label = "Align right" >
< AlignRightIcon />
</ Button >
</ Group >
</ div >
);
}
isPending shows a spinner, blocks further presses and keeps focus on the button, so keyboard and screen-reader users aren't thrown back to the top of the page. The button also announces its busy state.
Save changes
import { useState } from "react" ;
import { Button } from "@/components/ui/button" ;
export default function ButtonPending () {
const [ isPending , setPending ] = useState ( false );
return (
< Button
isPending = {isPending}
onPress = {() => {
setPending ( true );
setTimeout (() => setPending ( false ), 2000 );
}}
>
{isPending ? "Saving…" : "Save changes" }
</ Button >
);
}
Drive isPending from your own state to show progress and a success state for async work.
Save changes
import { CheckIcon } from "lucide-react" ;
import { useState } from "react" ;
import { Button } from "@/components/ui/button" ;
type Status = "idle" | "saving" | "saved" ;
export default function ButtonLoading () {
const [ status , setStatus ] = useState < Status >( "idle" );
async function save () {
setStatus ( "saving" );
await new Promise (( r ) => setTimeout (r, 1500 ));
setStatus ( "saved" );
setTimeout (() => setStatus ( "idle" ), 1500 );
}
return (
< Button
isPending = {status === "saving" }
color = {status === "saved" ? "success" : undefined }
onPress = {save}
className = "min-w-32"
>
{status === "saving" && "Saving…" }
{status === "saved" && (
<>
< CheckIcon /> Saved
</>
)}
{status === "idle" && "Save changes" }
</ Button >
);
}
isDisabled removes the button from the tab order and ignores presses. Prefer isPending while work is in flight, and consider explaining why an action is unavailable instead of silently disabling it.
import { Button } from "@/components/ui/button" ;
export default function ButtonDisabled () {
return (
< div className = "flex flex-wrap items-center gap-2" >
< Button isDisabled >Solid</ Button >
< Button isDisabled variant = "outline" >
Outline
</ Button >
< Button isDisabled variant = "ghost" >
Ghost
</ Button >
</ div >
);
}
Buttons default to type="button". Set type="submit" or type="reset" to take part in a form; native validation and FormData work as usual.
import { useState } from "react" ;
import { Form } from "react-aria-components" ;
import { Button } from "@/components/ui/button" ;
import { TextField } from "@/components/ui/text-field" ;
export default function ButtonForm () {
const [ submitted , setSubmitted ] = useState < string | null >( null );
return (
< Form
className = "flex w-full max-w-xs flex-col gap-4"
onSubmit = {( e ) => {
e. preventDefault ();
const data = Object. fromEntries ( new FormData (e.currentTarget));
setSubmitted ( JSON . stringify (data));
}}
onReset = {() => setSubmitted ( null )}
>
< TextField label = "Project name" name = "name" isRequired />
< div className = "flex gap-2" >
< Button type = "submit" >Create</ Button >
< Button type = "reset" variant = "ghost" >
Reset
</ Button >
</ div >
{submitted && (
< code className = "rounded-md bg-muted px-2 py-1 text-xs" >
{submitted}
</ code >
)}
</ Form >
);
}
Links must stay links for navigation to work (middle-click, "open in new tab", prefetching). Apply buttonVariants to a React Aria Link, Next.js Link or any element to borrow the button styles.
import { ArrowUpRightIcon } from "lucide-react" ;
import { Link } from "react-aria-components" ;
import { buttonVariants } from "@/components/ui/button" ;
export default function ButtonAsLink () {
return (
< Link
href = "#"
className = { buttonVariants ({ variant: "outline" , color: "neutral" })}
>
Documentation < ArrowUpRightIcon />
</ Link >
);
}
import NextLink from "next/link" ;
import { buttonVariants } from "@/components/ui/button" ;
< NextLink href = "/pricing" className = { buttonVariants ({ variant: "outline" })}>
See pricing
</ NextLink >;
Ghost icon buttons inside a React Aria Toolbar get arrow-key navigation between items, with tooltips that show the shortcut.
import {
BoldIcon,
ItalicIcon,
LinkIcon,
ListIcon,
ListOrderedIcon,
UnderlineIcon,
} from "lucide-react" ;
import { Toolbar } from "react-aria-components" ;
import { Button } from "@/components/ui/button" ;
import { Separator } from "@/components/ui/separator" ;
import { Tooltip, TooltipTrigger } from "@/components/ui/tooltip" ;
const groups = [
[
{ label: "Bold" , icon: BoldIcon, kbd: "⌘B" },
{ label: "Italic" , icon: ItalicIcon, kbd: "⌘I" },
{ label: "Underline" , icon: UnderlineIcon, kbd: "⌘U" },
],
[
{ label: "Bulleted list" , icon: ListIcon },
{ label: "Numbered list" , icon: ListOrderedIcon },
],
[{ label: "Insert link" , icon: LinkIcon, kbd: "⌘K" }],
];
export default function ButtonRecipeToolbar () {
return (
< Toolbar
aria-label = "Formatting"
className = "flex items-center gap-1 rounded-lg border bg-card p-1 shadow-xs"
>
{groups. map (( group , i ) => (
< div key = {group[ 0 ].label} className = "flex items-center gap-1" >
{i > 0 && < Separator orientation = "vertical" className = "mx-1 h-5" />}
{group. map (( a ) => (
< TooltipTrigger key = {a.label}>
< Button variant = "ghost" size = "icon-sm" aria-label = {a.label}>
< a.icon />
</ Button >
< Tooltip >
{a.label}
{ "kbd" in a && < span className = "ml-2 opacity-60" >{a.kbd}</ span >}
</ Tooltip >
</ TooltipTrigger >
))}
</ div >
))}
</ Toolbar >
);
}
A primary action attached to a Menu of alternatives.
import {
ChevronDownIcon,
ClockIcon,
FileTextIcon,
SendIcon,
} from "lucide-react" ;
import { Group } from "react-aria-components" ;
import { Button } from "@/components/ui/button" ;
import { MenuContent, MenuItem, MenuTrigger } from "@/components/ui/menu" ;
export default function ButtonRecipeSplit () {
return (
< Group
aria-label = "Send options"
className = "flex *:first:rounded-r-none *:last:rounded-l-none *:last:border-l *:last:border-l-white/20"
>
< Button >
< SendIcon /> Send now
</ Button >
< MenuTrigger >
< Button size = "icon" aria-label = "More send options" >
< ChevronDownIcon />
</ Button >
< MenuContent placement = "bottom end" >
< MenuItem textValue = "Schedule send" >
< ClockIcon /> Schedule send
</ MenuItem >
< MenuItem textValue = "Save as draft" >
< FileTextIcon /> Save as draft
</ MenuItem >
</ MenuContent >
</ MenuTrigger >
</ Group >
);
}
A primary and a secondary action, centered in an empty-state card.
No projects yet
Create your first project or import an existing one.
import { FolderPlusIcon, PlusIcon, UploadIcon } from "lucide-react" ;
import { Button } from "@/components/ui/button" ;
export default function ButtonRecipeEmptyState () {
return (
< div className = "flex w-full max-w-md flex-col items-center rounded-xl border border-dashed px-6 py-10 text-center" >
< span className = "flex size-10 items-center justify-center rounded-full bg-muted text-muted-foreground" >
< FolderPlusIcon className = "size-5" />
</ span >
< p className = "mt-4 font-medium" >No projects yet</ p >
< p className = "mt-1 text-muted-foreground text-sm" >
Create your first project or import an existing one.
</ p >
< div className = "mt-6 flex flex-wrap justify-center gap-2" >
< Button >
< PlusIcon /> New project
</ Button >
< Button variant = "outline" >
< UploadIcon /> Import
</ Button >
</ div >
</ div >
);
}
Renders a native <button>, so it's focusable, announced as a button and works with every assistive technology out of the box.
The focus ring only appears for keyboard focus (data-focus-visible), never on mouse click.
Icon-only buttons must have an aria-label (or aria-labelledby).
isPending sets aria-disabled and announces the busy state, but keeps the button in the tab order so focus isn't lost.
isDisabled sets the native disabled attribute and removes the button from the tab order.
Don't nest interactive elements (links, other buttons) inside a button.
Key Action Tab Moves focus to the button Space / Enter Presses the button
Style states with Tailwind's native data variants, e.g. data-pressed:scale-[0.98].
Attribute Present when data-hoveredHovered with a mouse or pen (never on touch) data-pressedBeing pressed data-focusedFocused by any means data-focus-visibleFocused with the keyboard data-disabledisDisabled is truedata-pendingisPending is truedata-slot="button"Always — target buttons from a parent with *:data-[slot=button]:…
Colors are applied through two CSS variables, so you can create a one-off tone without touching the variants:
< Button className = "[--tone:var(--color-pink-600)] [--tone-fg:white]" >
Custom tone
</ Button >
Variable Used for --toneBackground (solid), text and border (other variants), focus ring --tone-fgText on solid buttons
className and children also accept a function of the button's state:
< Button className = {({ isPressed }) => (isPressed ? "scale-95" : "" )}>
{({ isPending }) => (isPending ? "Saving…" : "Save" )}
</ Button >
The tailwind-variants function behind the component. Use it to style non-button elements, or to build your own variants on top.
import { buttonVariants } from "@/components/ui/button" ;
buttonVariants ({ variant: "soft" , color: "success" , size: "sm" });
Also accepts every prop of React Aria's Button , including focus, hover and keyboard handlers.
Toggle Button — a button with a persistent on/off state.
Toggle Button Group — single or multiple selection across a set of buttons.
Link — for navigation.
Menu — for a button that opens a list of actions.