# Button

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

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

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

## Installation

1. Install the following dependencies:

   ```bash
   npm install @base-ui/react @stylexjs/stylex clsx
   ```

2. Set up the token layer and the StyleX compiler, if you have not already. See
   [Installation](https://carbon.style/docs/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.

3. Copy and paste the following code into your project.

   ```tsx title="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,
   }
   ```

4. Update the import paths to match your project setup.

## Usage

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

```tsx
<Button variant="outline">Button</Button>
```

## Variants

Use the `variant` prop to change the visual style.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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>
  )
}
```

> **Note**
>
> 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](https://carbon.style/docs/components/spinner) with `iconPosition="start"` and
set `disabled` while the action is in flight.

```tsx
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.

```tsx
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](https://carbon.style/docs/components/button-group) does not move either.

```tsx
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>
  )
}
```

## As link

Spread the `buttonStyles` maps onto an anchor.

> **Warning**
>
> 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.

```tsx
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.

| Prop           | Type                                                                                 | Default     |
| -------------- | ------------------------------------------------------------------------------------ | ----------- |
| `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"`                                                         | —           |
| `xstyle`       | `StyleXStyles`                                                                       | —           |
| `style`        | `CSSProperties`                                                                      | —           |
| `className`    | `string`                                                                             | —           |

`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:

```tsx
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.

| Key         | What it is                                                            |
| ----------- | --------------------------------------------------------------------- |
| `base`      | Shape, type and disabled state, with the shared focus ring            |
| `variants`  | One namespace per `variant`, including hover styles                   |
| `sizes`     | One namespace per `size`                                              |
| `iconStart` | Leading-icon padding, keyed by size                                   |
| `iconEnd`   | Trailing-icon padding, keyed by size                                  |
| `pressable` | The 1px press, applied by `Button` to a lone raised variant           |
| `gloss`     | The sheen and reflection line, applied to `default` and `destructive` |
| `group`     | Inner-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](https://carbon.style/docs/components/button-group) 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

| Attribute            | On         | Effect                                                       |
| -------------------- | ---------- | ------------------------------------------------------------ |
| `data-slot="button"` | the button | Targets the button from a parent                             |
| `data-variant`       | the button | Picks the background the button holds while its menu is open |
| `data-size`          | the button | Reflects 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.
