V2 componentsUpdated Oct 19, 2026
Button
The action primitive. Variants carry intent, sizes carry density, and the same styling powers triggers in Dialog, Menu, and Sheet, so a button looks the same wherever it appears.
Source: packages/ui/src/components/button
import { Button, ButtonGroup, buttonVariants } from '@highlight/ui'Change the props; the preview and the snippet update together.
<Button>Send recap</Button>Two axes, not one list. Variant is emphasis, high to low: primary for the one action that matters, secondary for a filled second action, outline where a fill already sits behind it, ghost for toolbars and rows where a fill per item would be noise. Tone is what the button does, so a destructive action can sit quietly in a menu row or loudly in a dialog without changing component.
import { BUTTON_TONES, BUTTON_VARIANTS, Button } from '@highlight/ui'import { twMerge } from 'tailwind-merge' // The fixed tones are for grounds the theme does not own, so each sits on one: on-light on brand// yellow, on-dark on a fixed black. Both read the same in either theme.const FIXED_GROUND: Partial<Record<(typeof BUTTON_TONES)[number], string>> = { 'on-light': 'bg-brand', 'on-dark': 'bg-black-12',} export default function ButtonVariants() { return ( <div className="flex flex-col gap-3"> {BUTTON_TONES.map((tone) => ( <div key={tone} className={twMerge( 'corner-smooth flex flex-wrap items-center gap-2 rounded-xl', FIXED_GROUND[tone] && `${FIXED_GROUND[tone]} -mx-3 px-3 py-2`, )} > <span className={twMerge( 'text-foreground-secondary w-16 shrink-0 text-xs', tone === 'on-light' && 'text-on-light', tone === 'on-dark' && 'text-on-dark', )} > {tone} </span> {BUTTON_VARIANTS.map((variant) => ( <Button key={variant} variant={variant} tone={tone}> {variant} </Button> ))} </div> ))} </div> )}28 is the default. Type holds at 13px from sm through xl, so a toolbar does not read as a ransom note.
import { BUTTON_SIZES, Button } from '@highlight/ui' /** Height, type step and icon, from the table in the cva. The box carries the scale, not the type. */const SPECS: Record<(typeof BUTTON_SIZES)[number], string> = { xs: '20 · 12px · icon 12', sm: '24 · 13px · icon 14', md: '28 · 13px · icon 14', lg: '32 · 13px · icon 14', xl: '40 · 13px · icon 14', '2xl': '48 · 15px · icon 16',} export default function ButtonSizes() { return ( <div className="flex flex-wrap items-end justify-center gap-4"> {BUTTON_SIZES.map((size) => ( <div key={size} className="flex flex-col items-center gap-2"> <Button size={size}>{size}</Button> <span className="text-foreground-secondary whitespace-nowrap text-xs tabular-nums">{SPECS[size]}</span> </div> ))} </div> )}square and circle make icon-only buttons; always add an aria-label. rounded keeps the label with pill corners.
import { ArrowUp } from 'lucide-react'import { Button } from '@highlight/ui' export default function ButtonShapes() { return ( <div className="flex flex-wrap items-center justify-center gap-3"> <Button shape="rounded" title='shape="rounded" — a pill with a label'> Rounded </Button> <Button aria-label="Send" title='shape="square" — icon only, squircle corners' shape="square"> <ArrowUp /> </Button> <Button aria-label="Send" title='shape="circle" — icon only, radius clamped to half the height' shape="circle"> <ArrowUp /> </Button> <Button aria-label="Send" title='shape="circle" size="sm" variant="primary"' shape="circle" size="sm" variant="primary" > <ArrowUp /> </Button> </div> )}left and right accept any node; lucide icons are sized for you.
import { ArrowLeft, ArrowRight } from 'lucide-react'import { Button } from '@highlight/ui' export default function ButtonIcons() { return ( <div className="flex flex-wrap items-center justify-center gap-3"> <Button left={<ArrowLeft />}>Back</Button> <Button right={<ArrowRight />}>Next</Button> <Button variant="primary" left={<ArrowLeft />} right={<ArrowRight />}> Both </Button> </div> )}hotkey renders keycap chips from a "cmd+k"-style string. Aliases like cmd, shift, enter, esc map to symbols.
import { Button } from '@highlight/ui' export default function ButtonHotkey() { return ( <div className="flex flex-wrap items-center justify-center gap-3"> <Button hotkey="cmd+enter">Send</Button> <Button variant="primary" hotkey="cmd+k"> Search </Button> <Button variant="outline" size="sm" hotkey="esc"> Dismiss </Button> </div> )}loading swaps in a spinner and blocks clicks but keeps its colours, because the press already landed. disabled swaps to the disabled background and text, and keeps the variant recognisable.
import { useState } from 'react'import { ArrowUp } from 'lucide-react'import { Button } from '@highlight/ui' export default function ButtonStates() { const [loading, setLoading] = useState(false) const submit = () => { setLoading(true) setTimeout(() => setLoading(false), 1500) } return ( <div className="flex flex-wrap items-center justify-center gap-3"> <Button variant="primary" loading={loading} onClick={submit}> {loading ? 'Saving' : 'Save'} </Button> <Button loading>Loading</Button> <Button loading aria-label="Send" shape="circle"> <ArrowUp /> </Button> <Button disabled>Disabled</Button> <Button disabled variant="primary"> Disabled </Button> </div> )}href renders an anchor with the same styling. target="_blank" adds the external-link hint and defaults rel to noopener noreferrer; disabled drops the href and sets aria-disabled, since an anchor has no disabled.
import { ArrowLeft, ArrowUp } from 'lucide-react'import { Button } from '@highlight/ui' export default function ButtonLinks() { return ( <div className="flex flex-wrap items-center justify-center gap-3"> <Button href="https://highlightai.com" target="_blank"> External link </Button> <Button href="#" variant="primary"> Sign up </Button> <Button href="#" variant="outline" left={<ArrowLeft />}> Back </Button> <Button aria-label="Top" href="#" shape="square"> <ArrowUp /> </Button> </div> )}ButtonGroup joins adjacent Buttons into one control: one action with a side door, like Copy and a menu. The first and last keep their outer corners, and each segment after the first draws a hairline seam on its left edge; outline segments share their own hairline instead.
import { ChevronDown, Copy, Share } from 'lucide-react'import { Button, ButtonGroup } from '@highlight/ui' export default function ButtonGroupSplit() { return ( <div className="flex flex-col items-center gap-4"> <ButtonGroup> <Button left={<Copy />}>Copy</Button> <Button aria-label="More" shape="square"> <ChevronDown /> </Button> </ButtonGroup> <ButtonGroup> <Button left={<Copy />}>Copy</Button> <Button left={<Share />}>Share</Button> <Button aria-label="More" shape="square"> <ChevronDown /> </Button> </ButtonGroup> </div> )}Set variant, tone and size once on the group and every Button without its own takes them. A child can still override, so a destructive side door can sit in a neutral group.
import { Check, ChevronDown, Copy } from 'lucide-react'import { Button, ButtonGroup } from '@highlight/ui' export default function ButtonGroupInherited() { return ( <div className="flex flex-col items-center gap-4"> <ButtonGroup variant="primary"> <Button left={<Copy />}>Copy</Button> <Button aria-label="More" shape="square"> <ChevronDown /> </Button> </ButtonGroup> <ButtonGroup variant="primary" tone="success" size="sm"> <Button left={<Check />}>Copied</Button> <Button aria-label="More" shape="square"> <ChevronDown /> </Button> </ButtonGroup> <ButtonGroup variant="outline" size="xl"> <Button left={<Copy />}>Copy</Button> <Button aria-label="More" shape="square" variant="primary" tone="danger"> <ChevronDown /> </Button> </ButtonGroup> </div> )}layout="shell" draws one frame in the group’s variant at its size, and sets ghost Buttons a size down inside it: a few peers sharing one control, like the feedback thumbs.
import { Pause, Square, ThumbsDown, ThumbsUp } from 'lucide-react'import { Button, ButtonGroup } from '@highlight/ui' export default function ButtonGroupShell() { return ( <div className="flex flex-col items-center gap-4"> <ButtonGroup layout="shell" variant="outline"> <Button aria-label="Good response" shape="square"> <ThumbsUp /> </Button> <Button aria-label="Bad response" shape="square"> <ThumbsDown /> </Button> </ButtonGroup> <ButtonGroup layout="shell" variant="secondary" size="lg"> <Button left={<Pause />}>Pause</Button> <Button left={<Square />}>Stop</Button> </ButtonGroup> </div> )}The root, then any compound sub-components. Every part accepts className and forwards the rest of its props to the element or Base UI part beneath it.
Button- A button, or an anchor when href is set. variant, tone, size and shape set its look.
Props added on top of the underlying element or Base UI part.
| Prop | Type | Default | Description |
|---|---|---|---|
variant | 'primary'|'secondary'|'outline'|'ghost' | 'primary' | How much emphasis the button carries. Inherits from a surrounding ButtonGroup when omitted. |
tone | 'neutral'|'danger'|'success'|'on-light'|'on-dark'|'inverse' | 'neutral' | What the button does. Crosses with variant, so a destructive action can sit quietly in a menu row or loudly in a dialog. on-light and on-dark are fixed colours for a background the theme does not own (a photo, a brand fill), from the on-light and on-dark alpha ramps, so they never flip with the theme. inverse is the theme’s contrast colour: on-light in light, on-dark in dark, for a black call to action on the theme’s own ground. |
size | 'xs'|'sm'|'md'|'lg'|'xl'|'2xl' | 'md' | Heights 20 / 24 / 28 / 32 / 40 / 48. Corners 8 / 9 / 10 / 12 / 20 / 24, smoothed by corner-control, from the --radius-size-* tokens Input and Select share. Icons step with the text, not the box, so lg and xl both carry a 14px glyph. |
shape | 'rounded' | 'square' | 'circle' | — | square and circle are icon-only. |
left / right | ReactNode | — | Leading and trailing slots. |
hotkey | string | — | Keycap chips, e.g. "cmd+enter". |
href | string | — | Renders an <a> instead of a <button>. target, rel and download come with it. |
block | boolean | false | Fill the container instead of sizing to the label. |
loading | boolean | false | Shows a spinner in place of the leading slot (or the content for icon buttons) and disables the button. |
- Renders a native <button>, or an <a> once href is set; attributes pass through either way and a ref reaches the element.
- buttonVariants is the cva used for the styling, exported so triggers elsewhere can share it.
- BUTTON_VARIANTS, BUTTON_TONES, BUTTON_SIZES and BUTTON_SHAPES are read off the same maps the cva styles from, so the playground and the examples cannot list an option the component does not have.
- ButtonGroup takes variant, tone and size as fallbacks for its Buttons, plus layout: split (the default) or shell. Menu.Trigger and other button-shaped triggers resolve through the same context, so they join a group like a Button.
- loading sets aria-busy and disables the button, but keeps its colours: the spinner already says the press landed.