← ui

button

ui

a button in either material: shadcn's variants and sizes, toggle and loading

npx shadcn add @ag/button
Already installed? Preview an update
npx shadcn add @ag/button --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-14
  • Sizes past the input's heights, in Tailwind's steps, for the one press a section is about (a call to action): xl 48 px, 2xl 56 px, 3xl 64 px. In glass each is a pill like the others, its radius half its height. Before this, a taller button made by overriding the height kept the smaller size's corner.
  • icon and iconEnd: the icon in its own slot, sized by the button and a step dimmer than the words (60%, 90% under the hand), so the words lead. Only the slot dims, never an image that is part of the words. Migration: move an icon written as a child into icon (<Button icon={<Download />}>save</Button>). A child icon still draws, but it no longer dims. An icon-only button (icon-*, the icon as its child) keeps its icon whole.
Updated 2026-10-14 · release notes

shelf controls

uses material, spinner

used by floating, selection, breadcrumb, components, account-menu, vote, thumbs, reactions, verdict, calendar, log, setting, form, toast, player, timeline, film-mosaic, deployment, trace, email-address, browser, social-profile

the demo above, verbatim

button.demo.tsx
"use client"

import { Bold, ChevronDown, Download, Plus, Trash2 } from "lucide-react"
import Link from "next/link"
import * as React from "react"
import { Sample } from "@/app/samples"
import { Button, ButtonGroup } from "@/registry/base-nova/ui/button"
import { MaterialProvider } from "@/registry/base-nova/ui/material"

// #region variants
function Variants() {
  return (
    <div className="flex flex-wrap items-center gap-2">
      <Button>save</Button>
      <Button variant="secondary">cancel</Button>
      <Button variant="outline" icon={<Download />}>
        export
      </Button>
      <Button variant="ghost">skip</Button>
      <Button variant="destructive" icon={<Trash2 />}>
        delete
      </Button>
      <Button variant="link">learn more</Button>
    </div>
  )
}
// #endregion

// #region work
function Saving() {
  const [loading, setLoading] = React.useState(false)
  const [bold, setBold] = React.useState(false)
  return (
    <div className="flex flex-wrap items-center gap-2">
      <Button
        loading={loading}
        onClick={() => {
          setLoading(true)
          window.setTimeout(() => setLoading(false), 1500)
        }}
      >
        save changes
      </Button>
      <Button
        variant="outline"
        size="icon"
        aria-label="bold"
        pressed={bold}
        onClick={() => setBold((b) => !b)}
      >
        <Bold />
      </Button>
    </div>
  )
}
// #endregion

export default function ButtonDemo() {
  return (
    <div className="space-y-8">
      <Sample
        name="materials"
        title="every variant, plain and glass"
        description="one component; MaterialProvider turns the glass on for everything inside it, over a picture here so the glass has something to be glass over."
        with="variants"
      >
        <div className="grid gap-4">
          <Variants />
          <MaterialProvider material="glass">
            <div className="rounded-xl bg-[url(/lightbox/lake.jpg)] bg-cover bg-center p-6">
              <Variants />
            </div>
          </MaterialProvider>
        </div>
      </Sample>
      <Sample
        name="sizes"
        glance
        title="sizes, the input's heights"
        description="28, 32 and 40 px, so a field and its button sit flush in one row."
      >
        <div className="flex flex-wrap items-center gap-2">
          <Button size="sm">small</Button>
          <Button>medium</Button>
          <Button size="lg">large</Button>
          <Button size="icon-sm" variant="secondary" aria-label="add">
            <Plus />
          </Button>
          <Button size="icon" variant="secondary" aria-label="add">
            <Plus />
          </Button>
          <Button size="icon-lg" variant="secondary" aria-label="add">
            <Plus />
          </Button>
        </div>
      </Sample>
      <Sample
        name="work"
        title="loading, and a toggle"
        description="loading keeps the width and the focus; pressed is a toggle's state, read as aria-pressed."
        with="work"
      >
        <div className="grid gap-4">
          <Saving />
          <MaterialProvider material="glass">
            <div className="rounded-xl bg-[url(/lightbox/peaks.jpg)] bg-cover bg-center p-6">
              <Saving />
            </div>
          </MaterialProvider>
        </div>
      </Sample>
      <Sample
        name="joined"
        title="buttons joined into one"
        description="a split button, the action and its options: plain, each button its own face, joined; glass, one pane the buttons are parts of."
      >
        <div className="grid gap-4">
          <div className="flex flex-wrap gap-3">
            <ButtonGroup>
              <Button>Deploy</Button>
              <Button size="icon" aria-label="deploy options">
                <ChevronDown />
              </Button>
            </ButtonGroup>
            <ButtonGroup>
              <Button variant="outline">Copy</Button>
              <Button variant="outline">Paste</Button>
              <Button variant="outline">Cut</Button>
            </ButtonGroup>
          </div>
          <MaterialProvider material="glass">
            <div className="flex flex-wrap gap-3 rounded-xl bg-[url(/lightbox/lake.jpg)] bg-cover bg-center p-6">
              <ButtonGroup>
                <Button>Deploy</Button>
                <Button size="icon" aria-label="deploy options">
                  <ChevronDown />
                </Button>
              </ButtonGroup>
              <ButtonGroup>
                <Button>Copy</Button>
                <Button>Paste</Button>
                <Button>Cut</Button>
              </ButtonGroup>
            </div>
          </MaterialProvider>
        </div>
      </Sample>
      <Sample
        name="call"
        title="a call to action"
        description="xl, 2xl and 3xl, past the input's heights, for the one press a section is about. the icon in its slot (icon), a step dimmer than the words."
      >
        <MaterialProvider material="glass">
          <div className="flex flex-wrap items-center gap-4 rounded-xl bg-[url(/lightbox/lake.jpg)] bg-cover bg-center p-10">
            <Button size="xl" icon={<Download />}>
              save
            </Button>
            <Button size="2xl" icon={<Download />}>
              save
            </Button>
            <Button size="3xl" icon={<Download />}>
              save contact
            </Button>
          </div>
        </MaterialProvider>
      </Sample>
      <Sample name="link" title="a link that looks like a button">
        <Button render={<Link href="/components" />} variant="outline">
          all the controls
        </Button>
      </Sample>
    </div>
  )
}

the source, every file an install writes

button.tsx
"use client"

import { Button as BaseButton } from "@base-ui/react/button"
import { useRender } from "@base-ui/react/use-render"
import * as React from "react"
import { cn } from "@/lib/utils"
import {
  type Material,
  type Scheme,
  useMaterial,
  useSurfaceScheme,
} from "@/registry/base-nova/ui/material"
import {
  glassStyle,
  glassVariants,
  schemeOf,
} from "@/registry/base-nova/ui/material-glass"
import { Spinner, type SpinnerSize } from "@/registry/base-nova/ui/spinner"

// THE BUTTON, one behaviour in two materials. Base UI's button underneath, so it is a
// real <button> (or, with `render`, a link that behaves as one), keeps its focus when
// a `loading` press makes it wait, and reports `data-disabled`. shadcn's six variants
// and its sizes, with two differences, both so a row of controls reads as one:
//
//   the heights are the input's (28, 32, 40 px), so a field and its button sit flush
//   in a row (shadcn's 32/36/40 leave the default a step taller than its field);
//   the colours are the registry's: the page's own foreground at alpha tiers for the
//   quiet variants, `tone-bad` (@ag/tokens) for destructive, one red everywhere.
//
// `material="glass"` draws the same button in liquid glass (material-glass): the
// collection's look, its rim lighting up under the hand, a press swelling it on a
// spring. Each variant keeps its meaning there: default is the glass itself, secondary
// a quieter glass, outline the rim alone, ghost nothing until the hand comes,
// destructive a glass stained red, link a line of text.
//
// `pressed` makes it a toggle: on, it wears the text's colour faintly, in its fill or
// its glass, never a change of size. Its state is said ONE way: `aria-pressed` on a
// fixed name, or, with `on`/`off`, the name itself changing ("follow", "following")
// and no aria-pressed, which would say it twice. `loading` keeps its width (the label
// stays, unseen but still its name) and turns a spinner in its middle; it cannot be
// pressed again. `scheme` is the colour scheme it stands in (material).

export type ButtonVariant =
  | "default"
  | "secondary"
  | "outline"
  | "ghost"
  | "destructive"
  | "link"
export type ButtonSize =
  | "sm"
  | "md"
  | "lg"
  | "xl"
  | "2xl"
  | "3xl"
  | "icon-sm"
  | "icon"
  | "icon-lg"
export type ButtonCorners = "pill" | "soft"

/** Every button, whatever it wears. An icon inside takes the size's icon size unless
 *  it sets one. */
const BASE =
  "relative inline-flex shrink-0 cursor-pointer items-center justify-center gap-1.5 whitespace-nowrap font-medium select-none data-disabled:cursor-not-allowed data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0"

/** Beside words the icon (`icon`, `iconEnd`) is a step dimmer than them, so the
 *  words lead and the icon only says what kind of press it is; brighter with the
 *  words under the hand. An icon alone is the label and stays whole (the `icon-*`
 *  sizes). */
const WORDS =
  "[&>[data-slot=button-icon]]:opacity-60 [&>[data-slot=button-icon]]:transition-opacity hover:[&>[data-slot=button-icon]]:opacity-90"

const SIZE: Record<ButtonSize, string> = {
  sm: `h-7 px-2.5 text-xs [&_svg:not([class*='size-'])]:size-3.5 ${WORDS}`,
  md: `h-8 px-3 text-[13px] [&_svg:not([class*='size-'])]:size-4 ${WORDS}`,
  lg: `h-10 px-4 text-sm [&_svg:not([class*='size-'])]:size-4 ${WORDS}`,
  // Past the input's heights: the one press a section is about (save a contact,
  // start), a call to action, in Tailwind's steps.
  xl: `h-12 gap-2 px-5 text-base [&_svg:not([class*='size-'])]:size-5 ${WORDS}`,
  "2xl": `h-14 gap-2.5 px-6 text-lg [&_svg:not([class*='size-'])]:size-5 ${WORDS}`,
  "3xl": `h-16 gap-3 px-8 text-xl [&_svg:not([class*='size-'])]:size-6 ${WORDS}`,
  "icon-sm": "size-7 [&_svg:not([class*='size-'])]:size-3.5",
  icon: "size-8 [&_svg:not([class*='size-'])]:size-4",
  "icon-lg": "size-10 [&_svg:not([class*='size-'])]:size-[18px]",
}

/** A glass pill's radius, half of `SIZE`'s height. */
const GLASS_PILL: Record<ButtonSize, string> = {
  sm: "[--glass-radius:14px]",
  md: "[--glass-radius:16px]",
  lg: "[--glass-radius:20px]",
  xl: "[--glass-radius:24px]",
  "2xl": "[--glass-radius:28px]",
  "3xl": "[--glass-radius:32px]",
  "icon-sm": "[--glass-radius:14px]",
  icon: "[--glass-radius:16px]",
  "icon-lg": "[--glass-radius:20px]",
}

const SPINNER: Record<ButtonSize, SpinnerSize> = {
  sm: "sm",
  md: "md",
  lg: "md",
  xl: "lg",
  "2xl": "lg",
  "3xl": "lg",
  "icon-sm": "sm",
  icon: "md",
  "icon-lg": "lg",
}

/** The plain faces: shadcn's default as the page's primary, the rest at the
 *  foreground's alpha tiers. */
const PLAIN: Record<ButtonVariant, string> = {
  default:
    "bg-primary text-primary-foreground hover:bg-primary/90 data-pressed:shadow-[inset_0_1px_3px_rgb(0_0_0/0.35)]",
  secondary:
    "bg-foreground/8 text-foreground hover:bg-foreground/12 data-pressed:bg-foreground/16",
  outline:
    "text-foreground ring-1 ring-foreground/15 ring-inset hover:bg-foreground/5 data-pressed:bg-foreground/10",
  ghost: "text-foreground hover:bg-foreground/8 data-pressed:bg-foreground/10",
  destructive:
    "bg-tone-bad text-white hover:bg-tone-bad/90 data-pressed:shadow-[inset_0_1px_3px_rgb(0_0_0/0.35)]",
  link: "h-auto px-0 text-foreground underline-offset-4 hover:underline",
}

/** The glass that answers the hand: its rim catches more light under the pointer and
 *  a press swells it on a spring that settles back slower than it came, as macOS
 *  glass controls do. Its rim answers to the button alone (set here, not inherited),
 *  so a card that brightens its own edge does not brighten the buttons inside it. */
const GLASS_LIVE =
  "[--glass-change-ease:cubic-bezier(0.34,1.56,0.64,1)] active:[--glass-change-duration:150ms] [--glass-moves:scale_var(--glass-change)] hover:[--glass-rim:1.5]! active:scale-[1.06] motion-reduce:active:scale-100"

/** On, a glass toggle wears the text's own colour in its glass, faintly, with a
 *  brighter rim: on and off differ in light, never in size. */
const GLASS_PRESSED =
  "data-pressed:[--glass-stain-amount:16%]! data-pressed:[--glass-stain:currentColor]! data-pressed:[--glass-rim:1.8]!"

/** The glass faces, over the material's surface. A face's dark half is the
 *  scheme's (`scheme-dark:`), never the page's alone, or a dark glass on a light
 *  page would take the light half. */
const GLASS: Record<ButtonVariant, string> = {
  default: GLASS_PRESSED,
  secondary: `[--glass-tint:rgb(238_241_245/0.4)] scheme-dark:[--glass-tint:rgb(16_16_18/0.35)] [--glass-rim:0.7] hover:[--glass-rim:1.2]! ${GLASS_PRESSED}`,
  // An outline is the glass's edge with no body under it (`edge: "only"`), and a line
  // all the way round, which the glass's specular edge (a catch of light at a
  // corner) is not: its own faint ring, in its words' colour.
  outline: `ring-1 ring-current/25 ring-inset ${GLASS_PRESSED}`,
  ghost: "",
  destructive: `text-tone-bad ${GLASS_PRESSED}`,
  link: "",
}

const hasHref = (props: unknown): boolean =>
  typeof props === "object" &&
  props !== null &&
  (props as { href?: unknown }).href !== undefined

/** A link in the button's face: the element it is handed, its own semantics kept. */
function ButtonLink({
  render,
  look,
  rest,
  children,
}: {
  render: React.ReactElement
  look: React.HTMLAttributes<HTMLElement> & Record<`data-${string}`, string>
  rest: Record<string, unknown>
  children: React.ReactNode
}) {
  const { ref, ...other } = rest as { ref?: React.Ref<HTMLElement> }
  return useRender({ render, ref, props: { ...other, ...look, children } })
}

export type ButtonProps = Omit<
  React.ComponentProps<typeof BaseButton>,
  "className" | "style"
> & {
  style?: React.CSSProperties
  variant?: ButtonVariant
  size?: ButtonSize
  /** A pill, or soft corners. Default: soft when plain, a pill in glass. */
  corners?: ButtonCorners
  material?: Material
  /** The colour scheme it stands in: the page's, or dark over a picture. Default
   *  the nearest MaterialProvider's. */
  scheme?: Scheme
  /** A toggle's state: on or off. Unset, it is a plain button. */
  pressed?: boolean
  /** A toggle's words on and off ("following", "follow"). Given both, the button
   *  holds the width of the wider, both laid in one cell with the other hidden, so
   *  pressing it never moves what sits beside it; `children` is then unused. */
  on?: React.ReactNode
  off?: React.ReactNode
  /** The press is being answered: a spinner in its middle, its width kept, no
   *  second press. */
  loading?: boolean
  /** What kind of press it is, before the words (`<Download />`): sized by the
   *  button, a step dimmer than the words. With no words, at an `icon-*` size, it
   *  is the label and stays whole (give it an `aria-label`). */
  icon?: React.ReactNode
  /** After the words: where the press leads (an arrow, a chevron). */
  iconEnd?: React.ReactNode
  className?: string
}

/** Inside a glass ButtonGroup: the group is the pane, so a button in it draws none
 *  of its own. */
const InGlassGroup = React.createContext(false)

/** A button that is a part of a glass group: no glass of its own, lit under the hand,
 *  its outer ends rounded as the group's pane is (the group's radius, inherited). */
const GLASS_PART =
  "rounded-none first:rounded-l-(--glass-radius) last:rounded-r-(--glass-radius) text-current transition-colors hover:bg-foreground/8 data-pressed:bg-foreground/12"

/** Buttons joined into one: a split button (an action, and its options under a
 *  chevron), or a run of actions read as one control.
 *
 *    <ButtonGroup>
 *      <Button>Visit</Button>
 *      <Button size="icon" aria-label="every domain"><ChevronDown /></Button>
 *    </ButtonGroup>
 *
 *  Plain, each button keeps its own face, the corners where two meet square and a
 *  hairline between them. Glass, the group is ONE pane of glass and its buttons its
 *  parts, lit under the hand: a glass face states one corner radius, so two joined
 *  panes could only be two pills touching. */
export function ButtonGroup({
  material,
  scheme,
  size = "md",
  className,
  children,
  ref,
  ...props
}: React.ComponentProps<"div"> & {
  material?: Material
  scheme?: Scheme
  /** The size of the buttons it holds, which sets a glass pane's pill radius. */
  size?: ButtonSize
}) {
  const m = useMaterial(material)
  const [s, here] = useSurfaceScheme<HTMLDivElement>(scheme, ref)
  const glass = m === "glass"
  return (
    // biome-ignore lint/a11y/useSemanticElements: a fieldset groups form fields; a run of action buttons is a group
    <div
      ref={here}
      role="group"
      data-slot="button-group"
      data-material={m}
      className={cn(
        "inline-flex items-stretch",
        schemeOf(s),
        glass
          ? [
              glassVariants({ shape: "surface" }),
              GLASS_PILL[size],
              "[&>*+*]:border-current/15 [&>*+*]:border-l",
            ]
          : [
              "[&>*:not(:first-child)]:rounded-l-none [&>*:not(:last-child)]:rounded-r-none",
              // Outlines draw their own edges: two side by side overlap by one pixel,
              // so their meeting edges are one line, never two.
              "[&>[data-variant=outline]+[data-variant=outline]]:-ml-px",
              // Solid faces meet with a hairline between them.
              "[&>*+*:not([data-variant=outline])]:border-current/15 [&>*+*:not([data-variant=outline])]:border-l",
            ],
        className,
      )}
      {...props}
    >
      <InGlassGroup.Provider value={glass}>{children}</InGlassGroup.Provider>
    </div>
  )
}

export function Button({
  variant = "default",
  size = "md",
  corners,
  material,
  scheme,
  pressed,
  on,
  off,
  loading = false,
  icon,
  iconEnd,
  disabled,
  render,
  nativeButton,
  className,
  style,
  children: words,
  ref,
  ...props
}: ButtonProps) {
  const m = useMaterial(material)
  // Over a picture it is read by the part of it under the button.
  const [s, own] = useSurfaceScheme<HTMLElement>(scheme, ref)
  const worded = on !== undefined && off !== undefined
  const face = (node: React.ReactNode, shown: boolean) => (
    <span
      aria-hidden={!shown}
      className={cn(
        "col-start-1 row-start-1 inline-flex items-center justify-center gap-[inherit]",
        !shown && "invisible",
      )}
    >
      {node}
    </span>
  )
  const label = worded ? (
    <span className="grid gap-[inherit]">
      {face(on, pressed === true)}
      {face(off, pressed !== true)}
    </span>
  ) : (
    words
  )
  // The icons in their slots, so what dims (`WORDS`) is the icon and never an
  // image that is part of the words.
  const children = (
    <>
      {icon && (
        <span data-slot="button-icon" className="inline-flex">
          {icon}
        </span>
      )}
      {label}
      {iconEnd && (
        <span data-slot="button-icon" className="inline-flex">
          {iconEnd}
        </span>
      )}
    </>
  )
  const part = React.useContext(InGlassGroup)
  const glass =
    !part && m === "glass" && variant !== "link" && variant !== "ghost"
  const pill = (corners ?? (m === "glass" ? "pill" : "soft")) === "pill"
  // Glass states its radius, which places its light too: a pill's is half its
  // height (an icon size is square, its pill a circle).
  const round = part
    ? undefined
    : glass
      ? pill
        ? GLASS_PILL[size]
        : "[--glass-radius:10px]"
      : pill
        ? "rounded-full"
        : m === "glass"
          ? "rounded-[10px]"
          : "rounded-md"
  const look = {
    "data-slot": "button",
    "data-variant": variant,
    "data-material": m,
    style:
      variant === "destructive" && m === "glass"
        ? {
            ...glassStyle({ tint: "var(--tone-bad)" }),
            ...style,
          }
        : style,
    className: cn(
      BASE,
      SIZE[size],
      schemeOf(s),
      variant !== "link" && round,
      glass && [
        glassVariants({
          shape: "surface",
          edge: variant === "outline" ? "only" : true,
        }),
        GLASS_LIVE,
      ],
      part
        ? GLASS_PART
        : m === "glass"
          ? variant === "ghost"
            ? "text-current transition-colors hover:bg-foreground/8 data-pressed:bg-foreground/12"
            : GLASS[variant]
          : ["transition-[color,background-color,box-shadow]", PLAIN[variant]],
      variant === "link" && PLAIN.link,
      className,
    ),
  }
  // A link wearing the button's look stays a link: Base UI's button gives anything
  // that is not a <button> `role="button"`, and a screen reader then calls the hero's
  // "read the essay" a button. It is never disabled, loading or a toggle; asked to be,
  // it screams.
  if (React.isValidElement(render) && hasHref(render.props)) {
    if (disabled || loading || pressed !== undefined || on || off)
      throw new Error(
        "Button: a link (render with an href) is never disabled, loading or a toggle",
      )
    return (
      <ButtonLink render={render} look={look} rest={{ ...props, ref: own }}>
        {children}
      </ButtonLink>
    )
  }
  return (
    <BaseButton
      {...look}
      data-loading={loading || undefined}
      data-pressed={pressed || undefined}
      aria-pressed={worded ? undefined : pressed}
      aria-busy={loading || undefined}
      disabled={disabled || loading}
      // Waiting, it keeps the focus the press gave it: a keyboard user is not
      // thrown back to the page's start while the work runs.
      focusableWhenDisabled={loading}
      render={render}
      nativeButton={nativeButton ?? render === undefined}
      {...props}
      ref={own}
    >
      {loading ? (
        // The label stays the button's name (transparent, never `invisible`, which
        // would take it out of the accessibility tree and leave "loading" as the
        // name); the spinner is the eye's, aria-busy already says it.
        <>
          <span className="inline-flex items-center gap-[inherit] opacity-0">
            {children}
          </span>
          <span aria-hidden className="absolute grid place-items-center">
            <Spinner size={SPINNER[size]} material={m} />
          </span>
        </>
      ) : (
        children
      )}
    </BaseButton>
  )
}