Skip to content
Highlight

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

tsx
import { Button, ButtonGroup, buttonVariants } from '@highlight/ui'

Change the props; the preview and the snippet update together.

Props
Variant
Tone
Size
Shape
Left
Right
Full width
Loading
Disabled
tsx
<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.

neutral
danger
success
on-light
on-dark
inverse
tsx
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.

20 · 12px · icon 12
24 · 13px · icon 14
28 · 13px · icon 14
32 · 13px · icon 14
40 · 13px · icon 14
48 · 15px · icon 16
tsx
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.

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

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

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

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

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.

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

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

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

PropTypeDefaultDescription
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 / rightReactNode—Leading and trailing slots.
hotkeystring—Keycap chips, e.g. "cmd+enter".
hrefstring—Renders an <a> instead of a <button>. target, rel and download come with it.
blockbooleanfalseFill the container instead of sizing to the label.
loadingbooleanfalseShows 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.