a button in either material: shadcn's variants and sizes, toggle and loading
npx shadcn add @ag/buttonAlready installed? Preview an update
npx shadcn add @ag/button --dry-runYour installed source stays unchanged until you update it. How updates work
- Sizes past the input's heights, in Tailwind's steps, for the one press a section is about (a call to action):
xl48 px,2xl56 px,3xl64 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. iconandiconEnd: 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 intoicon(<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.
shelf controls
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
"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
"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>
)
}