- Accordion
- Agent Thinking
- Alert
- Alert Dialog
- Announcement
- Aspect Ratio
- Attachment
- Avatar
- Badge
- Breadcrumb
- Bubble
- Button
- Button Group
- Calendar
- Card
- Carousel
- Checkbox
- Checkbox Card
- Code Block
- Collapsible
- Color Picker
- Combobox
- Command
- Composer
- Composer Loader
- Context Menu
- Data Grid
- Data Table
- Date Picker
- Dialog
- Direction
- Drawer
- Dropdown Menu
- Empty
- Field
- File Upload
- Hover Card
- Input
- Input Group
- Input Otp
- Item
- Kbd
- Label
- Marker
- Menubar
- Message
- Message Scroller
- Meter
- Native Select
- Navigation Menu
- Number Field
- Pagination
- Period Switcher
- Popover
- Progress
- Questionnaire
- Radio Card
- Radio Group
- Resizable
- Reveal
- Scroll Area
- Search Field
- Select
- Separator
- Sheet
- Sidebar
- Skeleton
- Slider
- Social Button
- Sonner
- Spinner
- Status Dot
- Switch
- Switch Card
- Table
- Tabs
- Tag Group
- Textarea
- Theme Toggle
- Time Field
- Toggle
- Toggle Group
- Tooltip
- Trace
- Tree View
- Agent Creator 01
- Agent Limits Card 01
- Agent Progress 01
- AI Chat 01
- AI Image Generation 01
- AI Profile 01
- Alerts Card 01
- Auth Card 01
- Calendar 01
- Composer 01
- Composer Attachments 01
- Composer Panel 01
- Finance Dashboard 01
- Home Dashboard 01
- Marketing Dashboard 01
- Medical Profile 01
- Meeting Scheduler 01
- Multi Agent Chat 01
- Notification Center 01
- Patient Info Card 01
- Project Board 01
- Questionnaire 01
- Settings Modal 01
- Sidebar 01
- Stat Cards 01
- Task List 01
- Web Search 01
- Activity Rings Card 01
- Area Chart Card 01
- Bar List Card 01
- Combo Chart Card 01
- Contributions Card 01
- Earnings Chart Card 01
- Funnel Chart Card 01
- Heatmap Chart Card 01
- Line Chart Card 01
- Most Active Days Card 01
- Orders Chart Card 01
- Radar Chart Card 01
- Radial Chart Card 01
- Revenue Chart Card 01
- Sankey Chart Card 01
- Scatter Chart Card 01
- Sleep Score Card 01
- Stage Bars Card 01
- Steps Card 01
Installation
$ pnpm dlx shadcn@latest add @cobalt/button
Button works with any theme and derives its gradients from --primary and --destructive. Add theme-cobalt for the exact gradient, border and shadow tokens.
$ pnpm dlx shadcn@latest add @cobalt/theme-cobalt
Variants
| Variant | Use |
|---|---|
default | The main action, on a vertical gradient. |
outline | Secondary actions, on a hairline border with soft shadow. |
secondary | Quiet filled actions. |
soft | A tinted action that does not compete with default. |
ghost | Toolbar and inline actions with no resting surface. |
destructive | Actions that cannot be undone, on a red gradient. |
ghost-muted | A ghost with muted text until hover. |
text | A ghost-muted at normal weight and tracking, for chips and nav rows. At size="xs" it is text-sm with 4px padding. |
muted | A resting --muted fill that steps to --accent on hover. |
link | An inline text action. Add h-auto p-0 to run in copy. |
link-muted | A lower-emphasis link. |
close | A circular dismiss control. Use it with an icon-* size. |
Unstyled
variant="unstyled" is a bare button for rows, table cells, chart bars and scrims. It has no padding, height, fill, radius, hover fill or press scale, and it keeps the focus ring. It ignores the size's geometry, so set the size and shape yourself with classes. size still controls text style (for example xs text).
Shape and tooltip
shape="pill"makes the button fully rounded at any size.radiussets the corner radius in px (4,6,8,10,12,14,16) over the size's default. Leave it out to keep the size's radius.variant="filled"is a grey fill (the border colour) that stays visible on a popover or card, wheresecondaryandcloseblend in. Pair it with an icon size.radius={6}makes a small rounded square on an icon size, for a filled icon button such as a collapse toggle.tooltip="Sort"wraps the button in a Tooltip that opens below after 300ms. Pass an object ofTooltipContentprops with the label aschildrento change the side or size. An icon-only button also gets the text as itsaria-label.
<Button variant="outline" size="icon-sm" tooltip="Sort">
<RiSortDesc />
</Button>Motion
Pressing scales the button to 0.98. Colors change in 150ms, and the gradient cross-fades on hover. With prefers-reduced-motion: reduce, the scale and transitions are off and color changes are instant. link, link-muted and close do not scale, and neither do popup triggers, because moving a trigger would move its popup's anchor.
Source
import { Button as ButtonPrimitive } from "@base-ui/react/button";
import { cva } from "class-variance-authority";
import type { VariantProps } from "class-variance-authority";
import { cn } from "cn";
import * as React from "react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
// Colors change in 150ms. The press scale goes in over 220ms and back over
// 420ms. Gradients cannot interpolate, so the hover gradient lives on ::before
// and fades in over the resting one. Every token has a fallback derived from
// --primary or --destructive, so the button also works without theme-cobalt.
// The solid bg-primary and bg-destructive show if relative colors are missing.
const buttonVariants = cva(
"group/button inline-flex shrink-0 cursor-pointer items-center justify-center rounded-[10px] border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap outline-none select-none [transition:background-color_150ms_ease,border-color_150ms_ease,box-shadow_150ms_ease,color_150ms_ease,filter_150ms_ease,scale_420ms_cubic-bezier(0.4,0,0.2,1)] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background active:not-aria-[haspopup]:scale-[0.98] active:not-aria-[haspopup]:[transition-duration:150ms,150ms,150ms,150ms,150ms,220ms] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 motion-reduce:transition-none motion-reduce:before:transition-none motion-reduce:active:not-aria-[haspopup]:scale-100 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
compoundVariants: [
{
className: "[&_svg:not([class*='size-'])]:size-3.5",
size: "icon-2xs",
variant: "close",
},
{
className: "[&_svg:not([class*='size-'])]:size-4.25",
size: "icon-xs",
variant: "close",
},
{
className:
"[&_svg]:[stroke-width:2.5px] [&_svg:not([class*='size-'])]:size-6",
size: ["icon-sm", "icon", "icon-lg"],
variant: "close",
},
{
// An inline text trigger at the small height: body size, tight padding.
className: "px-1 text-sm tracking-normal",
size: "xs",
variant: "text",
},
],
defaultVariants: {
shape: "default",
size: "default",
variant: "default",
},
variants: {
size: {
default:
"h-9 gap-1.5 px-2.75 in-data-[slot=button-group]:rounded-[8px] has-data-[icon=inline-end]:pr-1.75 has-data-[icon=inline-start]:pl-1.75 [&_svg:not([class*='size-'])]:size-5",
icon: "size-9 [&_svg:not([class*='size-'])]:size-5",
"icon-2xs":
"size-5 rounded-[4px] in-data-[slot=button-group]:rounded-[8px] [&_svg:not([class*='size-'])]:size-3",
"icon-lg": "size-10 [&_svg:not([class*='size-'])]:size-5",
"icon-sm":
"size-8 rounded-[8px] in-data-[slot=button-group]:rounded-[8px] [&_svg:not([class*='size-'])]:size-4.5",
"icon-xs":
"size-6 rounded-[4px] in-data-[slot=button-group]:rounded-[8px] [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-10 gap-1.5 px-2.75 has-data-[icon=inline-end]:pr-1.75 has-data-[icon=inline-start]:pl-1.75 [&_svg:not([class*='size-'])]:size-5",
sm: "h-8 gap-1 rounded-[8px] px-2.25 in-data-[slot=button-group]:rounded-[8px] has-data-[icon=inline-end]:pr-1.75 has-data-[icon=inline-start]:pl-1.75 [&_svg:not([class*='size-'])]:size-4.5",
xs: "h-6 gap-0.75 rounded-[4px] px-2.25 text-xs font-semibold tracking-[0.15px] in-data-[slot=button-group]:rounded-[8px] has-data-[icon=inline-end]:pr-1.75 has-data-[icon=inline-start]:pl-1.75 [&_svg:not([class*='size-'])]:size-3.5",
},
variant: {
close:
"rounded-full bg-[var(--surface-strong,var(--muted))] text-muted-foreground hover:text-foreground active:not-aria-[haspopup]:scale-100 [&_svg]:[stroke-width:2px] [&_svg_*]:[vector-effect:non-scaling-stroke]",
default:
"relative isolate bg-primary bg-[image:var(--gradient-button-primary-default,linear-gradient(180deg,oklch(from_var(--primary)_calc(l_+_0.08)_c_h),var(--primary)))] bg-clip-border bg-origin-border text-primary-foreground shadow-[shadow:var(--shadow-control,0_1px_2px_0_rgb(0_0_0/0.05))] before:pointer-events-none before:absolute before:-inset-px before:-z-10 before:rounded-[inherit] before:bg-[image:var(--gradient-button-primary-hover,linear-gradient(180deg,oklch(from_var(--primary)_calc(l_+_0.16)_c_h),oklch(from_var(--primary)_calc(l_+_0.08)_c_h)))] before:opacity-0 before:transition-opacity before:duration-150 hover:before:opacity-100 active:bg-[image:var(--gradient-button-primary-active,linear-gradient(180deg,var(--primary),oklch(from_var(--primary)_calc(l_-_0.06)_c_h)))] active:before:opacity-0 [&:disabled,&[aria-disabled=true]]:bg-[image:var(--gradient-button-primary-disabled,linear-gradient(180deg,var(--muted),var(--muted)))] [&:disabled,&[aria-disabled=true]]:text-muted-foreground [&:disabled,&[aria-disabled=true]]:opacity-100 [&:disabled,&[aria-disabled=true]]:shadow-none [&:disabled,&[aria-disabled=true]]:before:hidden",
destructive:
"relative isolate bg-destructive bg-[image:var(--gradient-button-destructive-default,linear-gradient(180deg,oklch(from_var(--destructive)_calc(l_+_0.06)_c_h),var(--destructive)))] bg-clip-border bg-origin-border text-[color:var(--destructive-foreground,var(--primary-foreground))] shadow-[shadow:var(--shadow-control,0_1px_2px_0_rgb(0_0_0/0.05))] before:pointer-events-none before:absolute before:-inset-px before:-z-10 before:rounded-[inherit] before:bg-[image:var(--gradient-button-destructive-hover,linear-gradient(180deg,oklch(from_var(--destructive)_calc(l_+_0.13)_c_h),oklch(from_var(--destructive)_calc(l_+_0.06)_c_h)))] before:opacity-0 before:transition-opacity before:duration-150 hover:before:opacity-100 active:bg-[image:var(--gradient-button-destructive-active,linear-gradient(180deg,var(--destructive),oklch(from_var(--destructive)_calc(l_-_0.07)_c_h)))] active:before:opacity-0 [&:disabled,&[aria-disabled=true]]:bg-[image:var(--gradient-button-destructive-disabled,linear-gradient(180deg,var(--muted),var(--muted)))] [&:disabled,&[aria-disabled=true]]:text-destructive/50 [&:disabled,&[aria-disabled=true]]:opacity-100 [&:disabled,&[aria-disabled=true]]:shadow-none [&:disabled,&[aria-disabled=true]]:before:hidden",
// The hover fill is --surface-hover, which is also the pressed fill: it
// reads on the composer bar and cards, where dark --muted (or half of
// it) is the same step as the surface and would not show at all.
ghost:
"hover:bg-[var(--surface-hover,var(--muted))] hover:text-foreground aria-expanded:bg-[var(--surface-hover,var(--muted))] aria-expanded:text-foreground",
"ghost-muted":
"text-muted-foreground hover:bg-[var(--surface-hover,var(--muted))] hover:text-foreground aria-expanded:bg-[var(--surface-hover,var(--muted))] aria-expanded:text-foreground",
// A ghost-muted at normal weight and tracking, for chips, nav rows and
// inline menu triggers that read as text until hovered.
text: "font-normal tracking-normal text-muted-foreground hover:bg-[var(--surface-hover,var(--muted))] hover:text-foreground aria-expanded:bg-[var(--surface-hover,var(--muted))] aria-expanded:text-foreground",
// A resting muted fill that steps to --accent on hover (secondary
// hovers by a 5% mix instead, filled uses the border colour).
muted:
"bg-muted text-muted-foreground hover:bg-accent hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground",
link: "text-primary underline-offset-3 hover:underline active:text-[color-mix(in_oklch,var(--primary),var(--foreground)_30%)] active:not-aria-[haspopup]:scale-100",
"link-muted":
"text-muted-foreground underline-offset-3 hover:underline active:text-foreground active:not-aria-[haspopup]:scale-100",
outline:
"border-border bg-popover text-foreground shadow-[shadow:var(--shadow-control,0_1px_2px_0_rgb(0_0_0/0.05))] hover:border-[var(--border-hover,var(--border))] hover:bg-[var(--surface-hover,var(--muted))] hover:text-foreground active:not-aria-[haspopup]:border-[var(--foreground-subtle,var(--border))] active:not-aria-[haspopup]:bg-[var(--surface-strong,var(--muted))] aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-popover dark:hover:border-[var(--border-hover,var(--input))] dark:hover:bg-[var(--surface-hover,color-mix(in_oklch,var(--input)_50%,transparent))] dark:active:not-aria-[haspopup]:border-[var(--foreground-subtle,var(--input))] dark:active:not-aria-[haspopup]:bg-[var(--surface-strong,color-mix(in_oklch,var(--input)_50%,transparent))] [&:disabled,&[aria-disabled=true]]:bg-muted [&:disabled,&[aria-disabled=true]]:text-muted-foreground/70 [&:disabled,&[aria-disabled=true]]:opacity-100 [&:disabled,&[aria-disabled=true]]:shadow-none",
secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] active:not-aria-[haspopup]:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_10%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
// A grey fill that stays visible on a popover or card surface, where
// secondary and close blend in (dark popover and --surface-strong are
// the same step). Uses the --border and --border-hover tokens.
filled:
"bg-border text-muted-foreground hover:bg-[var(--border-hover,var(--border))] hover:text-foreground active:not-aria-[haspopup]:bg-[var(--foreground-subtle,var(--border))] [&:disabled,&[aria-disabled=true]]:opacity-50",
// Bare element for rows, cells, chart bars and scrims: no padding,
// height, fill, hover or press scale. Keeps the focus ring and
// ignores the size's geometry (use `size` only for text styles).
unstyled:
"size-auto min-w-0 gap-0 rounded-none border-0 bg-transparent p-0 focus-visible:ring-offset-0 active:not-aria-[haspopup]:scale-100",
soft: "bg-[var(--button-soft,color-mix(in_oklch,var(--primary)_12%,transparent))] text-[color:var(--button-soft-foreground,var(--primary))] hover:bg-[var(--button-soft-hover,color-mix(in_oklch,var(--primary)_20%,transparent))] active:bg-[var(--button-soft-active,color-mix(in_oklch,var(--primary)_30%,transparent))] [&:disabled,&[aria-disabled=true]]:bg-[var(--button-soft-disabled,var(--muted))] [&:disabled,&[aria-disabled=true]]:text-[color:var(--button-soft-disabled-foreground,var(--muted-foreground))] [&:disabled,&[aria-disabled=true]]:opacity-100",
},
shape: {
default: "",
pill: "rounded-full in-data-[slot=button-group]:rounded-full",
},
// `none` drops the ring for a shape that draws its own keyboard focus
// outline (a chart segment), so no box surrounds it.
focusRing: {
default: "",
none: "focus-visible:ring-0",
},
// Corner radius in px. Left out by default so the size's radius stays.
radius: {
4: "rounded-[4px] in-data-[slot=button-group]:rounded-[4px]",
6: "rounded-[6px] in-data-[slot=button-group]:rounded-[6px]",
8: "rounded-[8px] in-data-[slot=button-group]:rounded-[8px]",
10: "rounded-[10px] in-data-[slot=button-group]:rounded-[10px]",
12: "rounded-[12px] in-data-[slot=button-group]:rounded-[12px]",
14: "rounded-[14px] in-data-[slot=button-group]:rounded-[14px]",
16: "rounded-[16px] in-data-[slot=button-group]:rounded-[16px]",
},
},
}
);
type ButtonTooltip =
| React.ReactNode
| (React.ComponentProps<typeof TooltipContent> & { delay?: number });
function Button({
className,
variant = "default",
size = "default",
shape = "default",
radius,
focusRing,
tooltip,
children,
...props
}: ButtonPrimitive.Props &
VariantProps<typeof buttonVariants> & {
/**
* Wraps the button in a Tooltip. Pass the label, or an object of
* TooltipContent props with the label as `children`. An icon-only button
* with a plain-text tooltip also gets that text as its aria-label.
*/
tooltip?: ButtonTooltip;
}) {
const isContentProps =
typeof tooltip === "object" &&
tooltip !== null &&
!React.isValidElement(tooltip) &&
!Array.isArray(tooltip);
const label = isContentProps
? (tooltip as React.ComponentProps<typeof TooltipContent>).children
: tooltip;
const hasTooltip =
tooltip !== undefined && tooltip !== null && tooltip !== false;
// A button rendered as a link (`render={<a />}` or a router Link, with
// nativeButton={false}) would otherwise be announced as role="button".
const renderedProps = React.isValidElement(props.render)
? (props.render.props as { href?: unknown })
: undefined;
const rendersLink =
props.nativeButton === false && renderedProps?.href !== undefined;
const buttonProps = {
"aria-label":
hasTooltip && typeof label === "string" && !props["aria-label"]
? label
: undefined,
className: cn(
buttonVariants({
className,
focusRing,
radius,
shape,
size,
variant,
})
),
"data-slot": "button",
role: rendersLink ? "link" : undefined,
...props,
};
if (!hasTooltip) {
return <ButtonPrimitive {...buttonProps}>{children}</ButtonPrimitive>;
}
const {
children: _label,
delay = 150,
...contentProps
} = isContentProps
? (tooltip as React.ComponentProps<typeof TooltipContent> & {
delay?: number;
})
: ({} as React.ComponentProps<typeof TooltipContent> & {
delay?: number;
});
return (
<ButtonTooltip
buttonProps={buttonProps}
contentProps={contentProps}
delay={delay}
label={label}
>
{children}
</ButtonTooltip>
);
}
// A button that opens a popover or menu gets `aria-expanded` from its trigger.
// While that popup is open the tooltip stays closed, and it does not reopen
// when focus returns to the button; the next hover or focus brings it back.
function ButtonTooltip({
buttonProps,
contentProps,
delay,
label,
children,
}: {
buttonProps: Record<string, unknown> & {
onBlur?: React.FocusEventHandler;
};
contentProps: React.ComponentProps<typeof TooltipContent>;
delay: number;
label: React.ReactNode;
children: React.ReactNode;
}) {
const [open, setOpen] = React.useState(false);
const suppressed = React.useRef(false);
const expanded =
buttonProps["aria-expanded"] === true ||
buttonProps["aria-expanded"] === "true" ||
buttonProps["data-popup-open"] !== undefined;
React.useEffect(() => {
if (expanded) {
suppressed.current = true;
setOpen(false);
}
}, [expanded]);
// Focus moves into the popup and back; only a blur after it closed clears
// the flag.
const release = () => {
if (!expanded) {
suppressed.current = false;
}
};
return (
<Tooltip
open={open && !expanded}
onOpenChange={(next, details) => {
if (
next &&
(expanded ||
(suppressed.current && details.reason === "trigger-focus"))
) {
return;
}
setOpen(next);
}}
>
<TooltipTrigger
delay={delay}
render={
<ButtonPrimitive
{...buttonProps}
onBlur={(event) => {
release();
buttonProps.onBlur?.(event);
}}
/>
}
>
{children}
</TooltipTrigger>
<TooltipContent {...contentProps}>{label}</TooltipContent>
</Tooltip>
);
}
export { Button, buttonVariants };