Button

Displays a button or a component that looks like a button.

import { Button } from "@/components/ui/button"

export default function ButtonDemo() {
  return <Button>Button</Button>
}

Installation

Install the following dependencies:

pnpm add @base-ui/react @stylexjs/stylex clsx

Set up the token layer and the StyleX compiler, if you have not already. See Installation, and paste states.ts from the same page. The button's focus ring and invalid ring come from it. The button also needs interop.css, which sizes its icons and holds the background a trigger keeps while its menu is open.

Copy and paste the following code into your project.

components/ui/button.tsx
"use client"

import { createContext, use } from "react"
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import * as stylex from "@stylexjs/stylex"
import type { StyleXStyles } from "@stylexjs/stylex"
import { clsx } from "clsx"

import { states } from "@/theme/states"
import {
  color,
  duration,
  ease,
  layer,
  leading,
  radius,
  shadow,
  space,
  text,
} from "@/theme/tokens.stylex"

const buttonOutlineBg = `light-dark(${color.background}, color-mix(in oklab, ${color.input} 30%, transparent))`
const buttonOutlineBorder = `light-dark(${color.border}, ${color.input})`
const buttonOutlineHoverBg = `light-dark(${color.muted}, color-mix(in oklab, ${color.input} 50%, transparent))`
const buttonGhostHoverBg = `light-dark(${color.muted}, color-mix(in oklab, ${color.muted} 50%, transparent))`
const buttonDestructiveFill = "oklch(0.577 0.245 27.325)"
const buttonDestructiveForeground = "oklch(0.985 0 0)"
const buttonDestructiveEdge = "oklch(0 0 0 / 20%)"
const buttonDestructiveSheen = "oklch(1 0 0 / 20%)"
const buttonReflection = "oklch(1 0 0 / 10%)"
const buttonSecondaryHoverBg = "light-dark(oklch(0.96 0 0), oklch(0.245 0 0))"

type ButtonGroupOrientation = "horizontal" | "vertical"

const ButtonGroupContext = createContext<{ orientation: ButtonGroupOrientation } | null>(null)

const styles = stylex.create({
  base: {
    display: "inline-flex",
    alignItems: "center",
    justifyContent: "center",
    whiteSpace: "nowrap",
    flexShrink: 0,
    userSelect: "none",
    borderRadius: "var(--carbon-button-radius)",
    borderWidth: 1,
    borderStyle: "solid",
    fontSize: text.sm,
    lineHeight: leading.sm,
    fontWeight: 500,
    transitionProperty: "color, background-color, border-color, box-shadow, opacity, transform",
    transitionDuration: duration.color,
    transitionTimingFunction: ease.standard,
    pointerEvents: { default: null, ":disabled": "none" },
    opacity: { default: 1, ":disabled": 0.5 },
    "--carbon-icon-size": space.s4,
    "--carbon-button-radius": radius.lg,
  },
  pressable: {
    transform: {
      default: null,
      "@media (hover: hover)": { default: null, ":active": "translateY(1px)" },
    },
  },
  gloss: {
    position: "relative",
    isolation: "isolate",
    "--carbon-button-sheen-opacity": { default: 0.5, ":hover": 1 },
    "::before": {
      content: "''",
      position: "absolute",
      inset: -1,
      zIndex: -1,
      borderRadius: "inherit",
      pointerEvents: "none",
      backgroundImage: "linear-gradient(to bottom, var(--carbon-button-sheen), transparent)",
      opacity: "var(--carbon-button-sheen-opacity)",
      transitionProperty: "opacity",
      transitionDuration: duration.sheen,
      transitionTimingFunction: ease.tactile,
    },
    "::after": {
      content: "''",
      position: "absolute",
      inset: -1,
      zIndex: -1,
      borderRadius: "inherit",
      pointerEvents: "none",
      backgroundImage: `linear-gradient(to bottom, ${buttonReflection} 46%, transparent 54%)`,
      mixBlendMode: "overlay",
    },
  },
})

const variants = stylex.create({
  default: {
    backgroundColor: color.primary,
    color: color.primaryForeground,
    boxShadow: {
      default: shadow.buttonPrimary,
      ":focus-visible": `${shadow.buttonPrimary}, ${shadow.ring}`,
    },
    "--carbon-border-color": color.buttonPrimaryEdge,
    "--carbon-button-sheen": color.buttonPrimarySheen,
    "--carbon-button-highlight": color.buttonPrimaryHighlight,
    "--carbon-button-drop": color.buttonDrop,
  },
  outline: {
    backgroundClip: "padding-box",
    "--carbon-border-color": buttonOutlineBorder,
    backgroundColor: { default: buttonOutlineBg, ":hover": buttonOutlineHoverBg },
    color: { default: null, ":hover": color.foreground },
    "--carbon-menu-open-bg": color.itemHighlightBg,
    "--carbon-menu-open-fg": color.foreground,
  },
  secondary: {
    backgroundColor: { default: color.buttonSecondaryBg, ":hover": buttonSecondaryHoverBg },
    backgroundImage: `linear-gradient(to bottom, ${color.buttonSecondaryTop}, transparent)`,
    backgroundOrigin: "border-box",
    "--carbon-border-color": color.buttonSecondaryEdge,
    "--carbon-button-highlight": color.buttonSecondaryHighlight,
    "--carbon-button-drop": color.buttonSecondaryDrop,
    color: color.secondaryForeground,
    boxShadow: {
      default: shadow.buttonSecondary,
      ":focus-visible": `${shadow.buttonSecondary}, ${shadow.ring}`,
    },
    "--carbon-menu-open-bg": color.secondary,
    "--carbon-menu-open-fg": color.secondaryForeground,
  },
  ghost: {
    "--carbon-border-color": color.transparent,
    backgroundColor: { default: null, ":hover": buttonGhostHoverBg },
    color: { default: null, ":hover": color.foreground },
    "--carbon-menu-open-bg": color.itemHighlightBg,
    "--carbon-menu-open-fg": color.foreground,
  },
  destructive: {
    backgroundColor: buttonDestructiveFill,
    color: buttonDestructiveForeground,
    borderColor: {
      default: buttonDestructiveEdge,
      ":focus-visible": `color-mix(in oklab, ${color.destructive} 40%, transparent)`,
    },
    boxShadow: {
      default: shadow.buttonDestructive,
      ":focus-visible": `${shadow.buttonDestructive}, ${shadow.ringInvalid}`,
    },
    "--carbon-button-sheen": buttonDestructiveSheen,
    "--carbon-button-highlight": color.buttonDestructiveHighlight,
    "--carbon-button-drop": color.buttonDrop,
  },
  link: {
    "--carbon-border-color": color.transparent,
    color: color.primary,
    textUnderlineOffset: "4px",
    textDecorationLine: { default: "none", ":hover": "underline" },
  },
})

const sizes = stylex.create({
  default: { height: space.s8, gap: space.s1_5, paddingInline: space.s2_5 },
  xs: {
    height: space.s6,
    gap: space.s1,
    "--carbon-button-radius": radius.md,
    paddingInline: space.s2,
    fontSize: text.xs,
    lineHeight: leading.xs,
    "--carbon-icon-size": space.s3,
  },
  sm: {
    height: space.s7,
    gap: space.s1,
    "--carbon-button-radius": radius.md,
    paddingInline: space.s2_5,
    fontSize: text.compact,
    lineHeight: leading.compact,
    "--carbon-icon-size": space.s3_5,
  },
  lg: { height: space.s9, gap: space.s1_5, paddingInline: space.s2_5 },
  icon: { width: space.s8, height: space.s8 },
  "icon-xs": {
    width: space.s6,
    height: space.s6,
    "--carbon-button-radius": radius.md,
    "--carbon-icon-size": space.s3,
  },
  "icon-sm": {
    width: space.s7,
    height: space.s7,
    "--carbon-button-radius": radius.md,
  },
  "icon-lg": { width: space.s9, height: space.s9 },
})

const iconStart = stylex.create({
  default: { paddingInlineStart: space.s2 },
  xs: { paddingInlineStart: space.s1_5 },
  sm: { paddingInlineStart: space.s1_5 },
  lg: { paddingInlineStart: space.s3 },
  icon: {},
  "icon-xs": {},
  "icon-sm": {},
  "icon-lg": {},
})

const iconEnd = stylex.create({
  default: { paddingInlineEnd: space.s2 },
  xs: { paddingInlineEnd: space.s1_5 },
  sm: { paddingInlineEnd: space.s1_5 },
  lg: { paddingInlineEnd: space.s3 },
  icon: {},
  "icon-xs": {},
  "icon-sm": {},
  "icon-lg": {},
})

const group = stylex.create({
  horizontal: {
    position: "relative",
    zIndex: { default: null, ":focus-visible": layer.raised },
    boxShadow: {
      default:
        "inset 0 1px 0 var(--carbon-button-highlight, transparent), 0 1px 0 var(--carbon-button-drop, transparent)",
      ":focus-visible": `inset 0 1px 0 var(--carbon-button-highlight, transparent), 0 1px 0 var(--carbon-button-drop, transparent), ${shadow.ring}`,
    },
    borderStartStartRadius: {
      default: 0,
      ":nth-child(1 of [data-slot])": "var(--carbon-button-radius)",
    },
    borderEndStartRadius: {
      default: 0,
      ":nth-child(1 of [data-slot])": "var(--carbon-button-radius)",
    },
    borderStartEndRadius: {
      default: 0,
      ":nth-last-child(1 of [data-slot])": "var(--carbon-button-radius)",
    },
    borderEndEndRadius: {
      default: 0,
      ":nth-last-child(1 of [data-slot])": "var(--carbon-button-radius)",
    },
    borderInlineStartWidth: { default: 0, ":nth-child(1 of [data-slot])": 1 },
  },
  vertical: {
    position: "relative",
    zIndex: { default: null, ":focus-visible": layer.raised },
    boxShadow: {
      default:
        "inset 0 1px 0 var(--carbon-button-highlight, transparent), 0 1px 0 var(--carbon-button-drop, transparent)",
      ":focus-visible": `inset 0 1px 0 var(--carbon-button-highlight, transparent), 0 1px 0 var(--carbon-button-drop, transparent), ${shadow.ring}`,
    },
    borderStartStartRadius: {
      default: 0,
      ":nth-child(1 of [data-slot])": "var(--carbon-button-radius)",
    },
    borderStartEndRadius: {
      default: 0,
      ":nth-child(1 of [data-slot])": "var(--carbon-button-radius)",
    },
    borderEndStartRadius: {
      default: 0,
      ":nth-last-child(1 of [data-slot])": "var(--carbon-button-radius)",
    },
    borderEndEndRadius: {
      default: 0,
      ":nth-last-child(1 of [data-slot])": "var(--carbon-button-radius)",
    },
    borderBlockStartWidth: { default: 0, ":nth-child(1 of [data-slot])": 1 },
  },
})

const buttonStyles = {
  base: [styles.base, states.focusRing],
  pressable: styles.pressable,
  gloss: styles.gloss,
  variants,
  sizes,
  iconStart,
  iconEnd,
  group,
}

type ButtonVariant = keyof typeof variants
type ButtonSize = keyof typeof sizes

type ButtonProps = Omit<ButtonPrimitive.Props, "className"> & {
  variant?: ButtonVariant
  size?: ButtonSize
  iconPosition?: "start" | "end" | "both"
  xstyle?: StyleXStyles
  className?: string
}

function Button({
  variant = "default",
  size = "default",
  iconPosition,
  xstyle,
  className,
  ...props
}: ButtonProps) {
  const grouped = use(ButtonGroupContext)
  const glossy = variant === "default" || variant === "destructive"
  const raised = glossy || variant === "secondary"

  const sx = stylex.props(
    styles.base,
    states.focusRing,
    glossy && styles.gloss,
    !grouped && raised && styles.pressable,
    variants[variant],
    sizes[size],
    (iconPosition === "start" || iconPosition === "both") && iconStart[size],
    (iconPosition === "end" || iconPosition === "both") && iconEnd[size],
    grouped && group[grouped.orientation],
    props["aria-invalid"] && states.invalidRing,
    xstyle,
  )

  return (
    <ButtonPrimitive
      data-slot="button"
      data-variant={variant}
      data-size={size}
      {...sx}
      {...props}
      className={clsx("carbon-icons", sx.className, className)}
    />
  )
}

export {
  Button,
  ButtonGroupContext,
  buttonStyles,
  type ButtonGroupOrientation,
  type ButtonSize,
  type ButtonVariant,
}

Update the import paths to match your project setup.

Usage

import { Button } from "@/components/ui/button"
<Button variant="outline">Button</Button>

Variants

Use the variant prop to change the visual style.

import { Button } from "@/components/ui/button"
import { HStack } from "@/components/ui/stack"

export default function ButtonVariants() {
  return (
    <HStack align="center" gap={2} wrap="wrap">
      <Button variant="default">Default</Button>
      <Button variant="outline">Outline</Button>
      <Button variant="secondary">Secondary</Button>
      <Button variant="ghost">Ghost</Button>
      <Button variant="destructive">Destructive</Button>
      <Button variant="link">Link</Button>
    </HStack>
  )
}

Size

Use the size prop to change the size of the button.

import { Button } from "@/components/ui/button"
import { HStack } from "@/components/ui/stack"

export default function ButtonSize() {
  return (
    <HStack align="center" gap={2} wrap="wrap">
      <Button size="xs">Extra small</Button>
      <Button size="sm">Small</Button>
      <Button size="default">Default</Button>
      <Button size="lg">Large</Button>
    </HStack>
  )
}

Icon

Use an icon-* size for a square button. Always give an icon-only button an aria-label. There is no text for a screen reader to announce.

import { Button } from "@/components/ui/button"
import { HStack } from "@/components/ui/stack"
import { Plus } from "lucide-react"

export default function ButtonIcon() {
  return (
    <HStack align="center" gap={2} wrap="wrap">
      <Button size="icon-xs" variant="outline" aria-label="Add">
        <Plus />
      </Button>
      <Button size="icon-sm" variant="outline" aria-label="Add">
        <Plus />
      </Button>
      <Button size="icon" variant="outline" aria-label="Add">
        <Plus />
      </Button>
      <Button size="icon-lg" variant="outline" aria-label="Add">
        <Plus />
      </Button>
    </HStack>
  )
}

With icon

Set iconPosition to the side the icon is on, so the button tightens its padding there.

import { Button } from "@/components/ui/button"
import { HStack } from "@/components/ui/stack"
import { ArrowRight, GitBranch } from "lucide-react"

export default function ButtonWithIcon() {
  return (
    <HStack align="center" gap={2} wrap="wrap">
      <Button variant="outline" iconPosition="start">
        <GitBranch />
        New branch
      </Button>
      <Button iconPosition="end">
        Continue
        <ArrowRight />
      </Button>
    </HStack>
  )
}

Without iconPosition the padding stays symmetric and the icon looks inset. StyleX has no :has(), so the button has to be told which side the icon is on.

Loading

Render a Spinner with iconPosition="start" and set disabled while the action is in flight.

import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"
import { HStack } from "@/components/ui/stack"

export default function ButtonLoading() {
  return (
    <HStack align="center" gap={2} wrap="wrap">
      <Button disabled iconPosition="start">
        <Spinner aria-hidden="true" />
        Deploying
      </Button>
      <Button variant="outline" disabled>
        Disabled
      </Button>
    </HStack>
  )
}

Rounded

Pass an xstyle with borderRadius: radius.full for a pill.

import * as stylex from "@stylexjs/stylex"
import { Button } from "@/components/ui/button"
import { HStack } from "@/components/ui/stack"
import { radius } from "@/theme/tokens.stylex"
import { ArrowUp } from "lucide-react"

const styles = stylex.create({
  pill: { borderRadius: radius.full },
})

export default function ButtonRounded() {
  return (
    <HStack align="center" gap={2} wrap="wrap">
      <Button xstyle={styles.pill}>Rounded</Button>
      <Button variant="outline" xstyle={styles.pill}>
        Rounded
      </Button>
      <Button size="icon" xstyle={styles.pill} aria-label="Send">
        <ArrowUp />
      </Button>
    </HStack>
  )
}

Pressed

The raised variants, default, secondary and destructive, move down a pixel while pressed. outline and ghost are flat and do not move. The rule is inside @media (hover: hover), so it does not apply on touch screens. A button inside a ButtonGroup does not move either.

import { Button } from "@/components/ui/button"
import { ButtonGroup } from "@/components/ui/button-group"
import { HStack } from "@/components/ui/stack"

export default function ButtonPressed() {
  return (
    <HStack align="center" gap={4} wrap="wrap">
      <Button>Press me</Button>
      <Button variant="secondary">Press me</Button>
      <ButtonGroup aria-label="No press feedback">
        <Button variant="outline">Grouped</Button>
        <Button variant="outline">Grouped</Button>
      </ButtonGroup>
    </HStack>
  )
}

Spread the buttonStyles maps onto an anchor.

Do not use <Button render={<a />} /> for links. Base UI's Button always applies role="button", which overrides the link role. Open-in-new-tab and middle click stop working, and a screen reader announces a button.

import * as stylex from "@stylexjs/stylex"
import { clsx } from "clsx"
import { buttonStyles } from "@/components/ui/button"
import { ExternalLink } from "lucide-react"

const sx = stylex.props(
  buttonStyles.base,
  buttonStyles.variants.outline,
  buttonStyles.sizes.default,
  buttonStyles.iconEnd.default,
)

export default function ButtonAsLink() {
  return (
    <a
      href="https://base-ui.com"
      target="_blank"
      rel="noopener noreferrer"
      data-variant="outline"
      {...sx}
      className={clsx("carbon-icons", sx.className)}
    >
      Base UI
      <ExternalLink />
    </a>
  )
}

API reference

Pass render to replace a part's element or compose it with another component.

Button

Wraps Base UI's Button and accepts every prop it does, render and nativeButton among them.

PropTypeDefault
variant"default" | "outline" | "secondary" | "ghost" | "destructive" | "link""default"
size"default" | "xs" | "sm" | "lg" | "icon" | "icon-xs" | "icon-sm" | "icon-lg""default"
iconPosition"start" | "end" | "both"—
xstyleStyleXStyles—
styleCSSProperties—
classNamestring—

xstyle is merged last, so it beats the variant and the size. className is concatenated with no conflict resolution. Use it for plain-CSS hooks.

buttonStyles

The style maps behind Button. Spread them onto any element:

import * as stylex from "@stylexjs/stylex"
import { buttonStyles } from "@/components/ui/button"
import { clsx } from "clsx"

const sx = stylex.props(buttonStyles.base, buttonStyles.variants.outline, buttonStyles.sizes.sm)

;<a href="/docs" data-variant="outline" {...sx} className={clsx("carbon-icons", sx.className)} />

Merge className from the stylex.props() result by hand. A second className attribute beside the spread replaces it.

KeyWhat it is
baseShape, type and disabled state, with the shared focus ring
variantsOne namespace per variant, including hover styles
sizesOne namespace per size
iconStartLeading-icon padding, keyed by size
iconEndTrailing-icon padding, keyed by size
pressableThe 1px press, applied by Button to a lone raised variant
glossThe sheen and reflection line, applied to default and destructive
groupInner-edge radii and shared borders, keyed by group orientation

base is two styles, the button's own and the shared states.focusRing. Set a border of your own through --carbon-border-color rather than borderColor. A flat borderColor replaces the property with all of its conditions, so the ring loses its focus colour. That is why the outline variant sets the variable.

Keep the carbon-icons class and the data-variant attribute. interop.css sizes icons off the class and styles an open menu trigger off the attribute.

ButtonGroupContext

The context a ButtonGroup provides and Button reads. It holds the group's orientation. It is exported so a component built from buttonStyles can join a group too.

Data attributes

AttributeOnEffect
data-slot="button"the buttonTargets the button from a parent
data-variantthe buttonPicks the background the button holds while its menu is open
data-sizethe buttonReflects the size prop, for styling from a parent

interop.css sizes icons only inside the carbon-icons class. Without it a child <svg> keeps its own size, which is 24px for a Lucide icon.