For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to documentation URLs. Homepage markdown is available at /llms.md/content.md. An agent skill is available at /.well-known/agent-skills/site-skill.md.

Button

The Button component from this design system, built on Base UI.

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

VariantUse
defaultThe main action, on a vertical gradient.
outlineSecondary actions, on a hairline border with soft shadow.
secondaryQuiet filled actions.
softA tinted action that does not compete with default.
ghostToolbar and inline actions with no resting surface.
destructiveActions that cannot be undone, on a red gradient.
ghost-mutedA ghost with muted text until hover.
textA ghost-muted at normal weight and tracking, for chips and nav rows. At size="xs" it is text-sm with 4px padding.
mutedA resting --muted fill that steps to --accent on hover.
linkAn inline text action. Add h-auto p-0 to run in copy.
link-mutedA lower-emphasis link.
closeA 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.
  • radius sets 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, where secondary and close blend 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 of TooltipContent props with the label as children to change the side or size. An icon-only button also gets the text as its aria-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 };