Button
Displays a button or a link that looks like a button.
import { Mail } from 'lucide-react'
import { Button } from 'ui'
export function ButtonDemo() {
return (
<div className="flex gap-3">
<Button variant="default">Button rest</Button>
<Button variant="default" loading>
Button loading
</Button>
<Button variant="default" icon={<Mail />}>
Button icon
</Button>
<Button variant="default" iconRight={<Mail />}>
Button iconRight
</Button>
</div>
)
}Usage
import { Button } from '@/components/ui/button'<Button variant="outline">Button</Button>Link
You can use the buttonVariants helper to create a link that looks like a button.
import { buttonVariants } from '@/components/ui/button'<Link className={buttonVariants({ variant: 'outline' })}>Click here</Link>Alternatively, you can set the asChild parameter and nest the link component.
<Button asChild>
<Link href="/login">Login</Link>
</Button>Examples
Sizes
Use the size prop to determine the size of the button.
Variants
Default
Used when no variant is specified. Prefer this unless another variant fits better, as below.
Primary
Use sparingly for data insertion, confirming purchases, and other strong positive actions. Because it is so prominent, aim for at most one primary button in a viewport.
Secondary
Can be used for signaling a data or config change, but not as serious as a primary button.
For destructive or side effect actions, use the destructive or warning variant.
Warning
Used for actions that might have a side effect, but not as serious as a destructive action.
Destructive (currently danger)
Used for actions that will have a serious destructive side effect, like deleting data.
prop variant will probably be changed to destructive in the future.
Outline
Used for secondary actions, or actions that are not as important as the primary action.
Ghost (currently text)
Used for actions that are not as important as the primary action, or for actions that are not as important as the primary action.
prop variant will probably be changed to ghost in the future.
Link
Used for actions that are not as important as the primary action, or for actions that are not as important as the primary action.
Icon-only
Render an icon in a button without accompanying text. Ensure the button is accessible by:
- Wrapping it in a Tooltip for sighted users.
- Adding an
aria-labelprop for screen readers. - Setting its
aria-describedbyprop toundefinedwhen the tooltip content repeats the label. Otherwise screen readers read the label twice.
Consider also squaring off the button container as shown in the example below. For the default tiny size (h-[26px]), use w-6.5 so the button is square.
Compact icon-only buttons may also benefit from the hit-area utility. This increases their tap target slightly each side without changing the visual layout. See the Table component for more information.
As child
Supports slot behavior with asChild prop.
Split with dropdown
Pair a button with a chevron DropdownMenu trigger when there are variations of the same action, or alternative ways to accomplish the same goal. The default or most likely option should be used on the exposed button.
When secondary actions are related but distinct (not alternatives to the primary action) display the primary action as a button and place the rest in an overflow menu instead. See Table multiple actions.
import { ChevronDown } from 'lucide-react'
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from 'ui'
export function ButtonSplitDropdownDemo() {
return (
<div className="flex w-fit">
<Button
type="button"
className="rounded-r-none hover:z-10 focus-visible:z-10 focus-visible:rounded-r-sm"
>
Primary action
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
aria-label="More actions"
className="shrink-0 rounded-l-none px-[4px] py-[5px] -ml-px focus-visible:z-10 focus-visible:rounded-l-sm"
icon={<ChevronDown />}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem>Secondary action</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive focus:text-destructive">
Destructive action
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}Ensure the middle border is shared rather than doubled-up. Do not use border-l-0 on the chevron button as that drops the divider on hover/focus. Instead:
- Primary action:
rounded-r-noneandhover:z-10so its border stacks above the chevron on hover. - Chevron trigger:
rounded-l-none,shrink-0,px-[4px] py-[5px], and-ml-pxto overlap the adjacent border by one pixel. - Both:
focus-visible:z-10so the focus ring stacks above the neighbour, andfocus-visible:rounded-r-sm/focus-visible:rounded-l-smso the squared-off edge is slightly rounded while the ring is shown. - Chevron trigger only:
aria-labeldescribing the menu (the icon is decorative).
Inside Admonition actions when layout="responsive": also use flex w-full @lg:w-auto with flex-1 @lg:flex-none on the primary action.
Default fill and floating buttons
The default variant is meant to read as raised chrome on whatever surface it sits on.
- Light: opaque
bg-card(a solid elevated plate). Opaque on purpose so the button can cover busy content underneath. - Dark: translucent
bg-muted(a foreground wash). That adapts to the local surface, but content can show through the fill.
Hover: light uses hover:bg-muted; dark uses dark:hover:bg-accent.
Floating over content
If a default button is absolutely or sticky-positioned over code, tables, maps, or other busy UI, wrap it in FloatingPlate so nothing bleeds through:
import { Copy } from 'lucide-react'
import { Button, FloatingPlate } from 'ui'
export function ButtonFloatingPlate() {
return (
<div className="relative w-full max-w-md overflow-hidden rounded-md border border-border">
<pre className="bg-surface-100 p-4 pr-16 font-mono text-xs text-foreground-light leading-relaxed">
{`select *
from projects
where status = 'ACTIVE_HEALTHY'
order by created_at desc
limit 20;`}
</pre>
<FloatingPlate className="absolute right-2 top-2">
<Button size="tiny" icon={<Copy />}>
Copy
</Button>
</FloatingPlate>
</div>
)
}Keep positioning, z-index, and hover/focus reveal on the plate's className. Use rounded="full" when the child is a pill. For clusters (split toggles, parallel actions), wrap the group once.
Do not override the button fill with bg-popover at the callsite. The plate owns occlusion; the button stays a normal default control.
Floating plate
FloatingPlate is an opaque bg-popover shell for floating default buttons (and small clusters).
import { Button, FloatingPlate } from 'ui'| Prop | Default | Notes |
|---|---|---|
rounded | lg | md, lg, or full. Match or exceed the child button radius. |
className | — | Positioning, opacity, gaps for clusters. |
See Floating over content for when to use it.
Accessibility
Keyboard focus is automatically handled:
- Enabled buttons default to
tabIndex={0}(keyboard accessible) - Buttons with native
disableddefault totabIndex={-1}(removed from tab order) - You can still override with an explicit
tabIndexprop when needed - Keyboard focus uses the shared
focus-ringutility; variants do not change ring colour
You therefore don't need to manually set tabIndex for buttons using native disabled.
When a disabled action has a non-obvious reason and needs a tooltip, add focusableWhenDisabled so keyboard users can still focus the control. See Disabled controls.
Focusable when disabled
Use focusableWhenDisabled with disabled when the action is blocked for a non-obvious reason and you need a tooltip or other explanation. The control stays in the tab order and uses aria-disabled instead of native disabled.
<Button disabled focusableWhenDisabled>
Pause project
</Button>In Studio, ButtonTooltip adds focusableWhenDisabled to disabled buttons with tooltip text automatically.