Button
buttonTriggers actions through solid, soft, outline, ghost, or danger variants.
Usage
Basic usage
The default is a solid button; soft, outline, ghost, and link make five variants in all.
<Button>Default</Button>
<Button variant="soft">Soft</Button>
<Button variant="outline">Stroke</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="link">Text link</Button>Size
xs / sm / md / lg: four heights of 24 / 32 / 40 / 48px.
<Button size="xs">Extra small</Button>
<Button size="sm">small</Button>
<Button size="md">medium</Button>
<Button size="lg">Large</Button>Dense toolbar (the 24px xs size)
Toolbars and table rows in an admin console are mostly 24px tall with a 12px font, so sm (32px) is one step too large for them. xs also tightens the radius and the icon gap, so no override classes are needed. Icon-only micro actions on the same row use iconXs (20px); the two are deliberately not the same height.
<Button size="xs" variant="outline">Record</Button>
<Button size="xs" variant="outline">Replay</Button>
<Button size="xs" variant="soft">Filtered</Button>
<Button size="iconXs" variant="ghost" tone="neutral" aria-label="More">
<ChevronDown className="size-4" />
</Button>Icon sizes match text sizes
The side length of iconSm/icon/iconLg matches the height of sm/md/lg respectively (32/40/48). Pair an icon size with the text size of the same name so the seam stays flush.
<Button size="sm">small</Button>
<Button size="iconSm" aria-label="More"><ChevronDown className="size-4" /></Button>
<Button size="md">medium</Button>
<Button size="icon" aria-label="More"><ChevronDown className="size-4" /></Button>
<Button size="lg">Large</Button>
<Button size="iconLg" aria-label="More"><ChevronDown className="size-5" /></Button>A 20px micro size for dense tables
iconXs is a 20px square meant for expanders and small row actions inside a table: the smallest iconSm (32px) would push compact rows taller. It deliberately matches no text size, so do not pair it with sm.
<Button variant="ghost" tone="neutral" size="iconXs" aria-label="Expand">
<ChevronDown className="size-4" />
</Button>The three dense icon sizes (20 / 24 / 28)
The icon size that matches the xs text size is icon24, not iconXs - the one named Xs is 20px and serves micro actions inside a table row, and raising it to 24px would push compact rows taller. icon28 pins the 28px row scale of Chip md and Sidebar menu items.
<Button size="xs" variant="outline">Filter</Button>
<Button size="icon24" variant="outline" muted aria-label="Clear filters">
<ChevronDown className="size-3.5" />
</Button>Semantic tones
tone says what kind of action this is, and it is orthogonal to variant. A solid neutral button is inverted, not grey.
<Button>Submit</Button>
<Button tone="success">Approve</Button>
<Button tone="warning">Reject</Button>
<Button tone="danger">Delete</Button>
<Button tone="neutral">Skip</Button>Tone by variant
The same tone in a different variant gives you the tinted, outlined, or ghost form, so none of them needs its own enum value.
<Button tone="success" variant="outline">Approve</Button>
<Button tone="warning" variant="outline">Reject</Button>
<Button tone="danger" variant="outline">Delete</Button>
<Button tone="success" variant="ghost">Approve</Button>
<Button tone="danger" variant="link">Delete</Button>Tinted semantic fill (soft)
A tinted semantic background with matching text, weighted between outline and solid: a secondary primary action, the cancel half of a pair, or a stateful trigger that shows a filter is on. The fill derives from the semantic colour itself through opacity, so it follows whenever the brand colour changes.
<Button variant="soft">Secondary action</Button>
<Button variant="soft" tone="success">Approve</Button>
<Button variant="soft" tone="warning">Reject</Button>
<Button variant="soft" tone="danger">Cancel</Button>
<Button variant="soft" tone="neutral">Skip</Button>The dashed stroke step (empty slots)
A dashed border is not decoration but a shape with a fixed meaning: this slot is empty, put something in it. A solid border says "this is a clickable box", a dashed one says "there is nothing here yet". outline dashed changes the stroke only and keeps the border colour following tone; soft has no border, so dashed adds one in the matching colour, which is the complete empty-slot shape.
<Button variant="outline" size="xs" dashed>Upload</Button>
<Button variant="soft" dashed>+ Add a unit manually</Button>
<Button variant="outline" tone="danger" dashed>Border colour still follows tone</Button>
<Button variant="outline" size="xs">Baseline: solid</Button>The muted emphasis step
The resting color drops one step to the secondary gray and returns to the tone's own color on hover. Secondary text links and icon buttons in dense rows want exactly this step - a ghost without muted is still body black, so existing call sites are unaffected. Only effective on ghost and link.
<Button variant="ghost" size="xs" muted>Show log</Button>
<Button variant="link" muted>Clear</Button>
<Button variant="link" tone="danger" muted>Delete</Button>
<Button variant="ghost" size="xs">For contrast: normal emphasis</Button>The muted step on outline
The border and the fill stay exactly as they are; only the resting text drops one step. The sole clickable box on a card and the inactive half of a two-state trigger both take this shape - swapping in ghost muted drops the border along with it, and the border is the very sentence "this is a clickable box".
<Button variant="outline" size="xs" muted>Abort</Button>
<Button variant="outline" size="xs" tone="danger" muted>Delete</Button>
<Button variant="outline" size="xs">For contrast: normal emphasis</Button>tone=current inherits the container color
Icon buttons inside a colored card should take the color of that container instead of being pulled back to body black. current means "set no color, leave it to inheritance", unlike the five semantic steps that all hand out an absolute color. Only effective on ghost and outline, and opt-in.
<div className="text-green-700">
<Button variant="ghost" size="iconXs" tone="current" aria-label="Move up">
<ChevronUp className="size-3" />
</Button>
</div>Full width
block stretches the button to the container width, for mobile primary actions and form footers.
<Button block>Sign in</Button>
<Button block variant="outline">Use another method</Button>Loading and disabling
loading disables the button and shows a spinner.
<Button loading>Loading</Button>
<Button disabled>Disabled</Button>When to use
Use Button for standard actions with solid, soft, outline, ghost, or link styling; brand or danger tones; an optional loading state; and a press-scale animation. Use ShimmerButton, RainbowButton, or PulsatingButton for a special-effect CTA, and ButtonGroup for related actions. When you need the styling without <button> semantics, call buttonVariants(...) to obtain the class name.
Import
import { Button, buttonVariants } from "@hulianui/ui"Props
| Name | Type | Default | Description |
|---|---|---|---|
| variant | "solid" | "soft" | "outline" | "ghost" | "link" | "solid" | Visual style. soft is a tinted semantic background with matching text, sitting between outline and solid in weight (see below). |
| tone | "brand" | "success" | "warning" | "danger" | "neutral" | "current" | "brand" | Semantic color tone (see the table below). current is not a semantic color but "set no color, inherit from the container", and is only effective on `ghost` and `outline` (see "Inheriting the container color"). |
| size | "xs" | "28" | "sm" | "md" | "lg" | "icon" | "iconSm" | "iconLg" | "iconXs" | "icon24" | "icon28" | "md" | Control size. xs (24) and "28" are the two dense text sizes, for admin toolbars, table rows and filter-pill rows. iconSm / icon / iconLg are square icon buttons whose side length matches the text size of the same name. The dense end has three more icon sizes, iconXs (20), icon24 (24) and icon28 (28), each pinned to one row scale (see the table below). |
| block | boolean | false | Stretches the button to the full container width, for mobile primary actions and form footers. |
| muted | boolean | false | Emphasis step: the resting color drops one level to the secondary gray and returns to the tone's own color on hover. Only effective on `ghost`, `link` and `outline` (see "The muted emphasis step"). |
| dashed | boolean | false | Stroke step: a dashed border, meaning "this slot is empty, put something in it". Only effective on `outline` and `soft` (see "The dashed stroke step"). |
| loading | boolean | false | Shows a spinner and disables the button. |
| type | "button" | "submit" | "reset" | "button" | Defaults to `button` rather than the native `<button>` default of `submit`, so a helper button inside a form does not submit it when type is omitted. Write type="submit" explicitly on submit buttons. |
| ...ButtonHTMLAttributes | ButtonHTMLAttributes<HTMLButtonElement> | - | Native attributes such as disabled. |
Events
| Event | Type | Description |
|---|---|---|
| onClick | (e: MouseEvent<HTMLButtonElement>) => void | Native click callback inherited from ButtonHTMLAttributes. |
Slots
| Slot | Type | Description |
|---|---|---|
| children | ReactNode | Button content. |
| render | ReactElement | Custom element such as <a> or Next.js <Link>. Button styles and aria-disabled are merged into this element. |
Semantic tones
Buttons are a two-dimensional model of `variant` (shape) × `tone` (meaning), not a flat one-dimensional type list: "a solid success button" and "an outlined success button" are orthogonal choices and do not need one enum value each.
| tone | Use it for | Solid appearance |
|---|---|---|
brand (default) | The primary action of the page: submit, save, next | Brand fill with white text |
success | Positive confirmations: approve, publish, enable | Success fill with its own foreground |
warning | Costly but non-destructive: reject, unpublish, force sync | Warning fill with its own foreground |
danger | Irreversible destruction: delete, deactivate, clear | Danger fill with its own foreground |
neutral | A secondary action of equal weight: cancel and go back, skip | Inverted fill (the foreground colour becomes the background) |
<Button tone="success">Approve</Button>
<Button tone="warning" variant="outline">Reject</Button>
<Button tone="danger">Delete</Button>
<Button tone="neutral">Skip</Button>Coming from a one-dimensional type model: type="primary" becomes a plain <Button>, type="success" becomes tone="success", and type="default" or plain becomes variant="outline". Hairline borders are already the library default, so there is nothing to opt into.
Tinted semantic fill (soft)
soft is a tinted semantic background with matching text (Radix calls it soft, MUI calls it tonal,
Ant calls it filled), and its visual weight sits between outline and solid. **A tinted fill is not
an outline**: outline keeps the canvas background and only adds a semantic border, so the fill never
gets lighter. When you want a tinted fill, use soft instead of writing bg-*-50 in className.
Three typical places:
- Secondary primary action: weaker than
solidso it does not compete with the page CTA, stronger thanoutlineso it still reads as a brand action. - The cancel or discard half of a pair:
variant="soft" tone="danger"carries the danger meaning without a full red block. - A stateful trigger:
variant={isActive ? "soft" : "outline"}shows that a filter is on.
<Button variant="soft">Secondary action</Button>
<Button variant="soft" tone="danger">Cancel</Button>
<Button variant="soft" tone="success" size="xs">Enabled</Button>The fill uses the opacity recipe the library already has: bg-{tone}/12, deepening to 20% on hover, withneutral on bg-foreground/8 so it adapts to light and dark, exactly as soft works on
Tag, Chip and Alert. It **deliberately avoids the--color-*-subtle tokens**: switching to them would mean minting a --color-primary-subtle plus four*-subtle-hover tokens, leaving the library with two parallel soft palettes where retuning one leaves the
other behind.
Known trade-off: the fill is translucent, so it picks up the background of whatever contains it. On a
coloured block the button will look off; report it with a screenshot rather than layering an opaque
background through className.
The muted emphasis step
The weakest color ghost, link and outline can reach is body black (tone="neutral" included), yet most secondary text links and icon buttons in everyday UI rest in the secondary gray and only return to body black on hover. muted adds that step (#211, #221):
<Button variant="ghost" size="xs" muted>Show log</Button>
<Button variant="link" muted>Clear</Button>
<Button variant="link" tone="danger" muted>Delete</Button> {/* gray at rest, red on hover */}
<Button variant="outline" size="xs" muted block>Abort</Button> {/* border kept, only the text drops */}The rule in one line: the resting color drops to `--color-muted-foreground` and returns to the tone's own color on hover (ghost and outline each keep their own hover background). So tone="danger" muted is the "gray at rest, red on hover" delete link rather than a discarded semantic color - a common shape in dense admin rows.
On outline it touches the text only: bg-surface, border-hairline and hover:bg-surface-hover all stay, as does the semantic border of a non-neutral tone (border-danger and friends). Reach for it whenever the border is what carries the message ("this is a clickable box") and only the text is too loud; ghost muted is not a substitute because it drops the border along with the color. The typical spot is the inactive half of a two-state trigger: in variant={active ? "soft" : "outline"}, the inactive half is supposed to be one step weaker than the active one.
Three boundaries:
- Only effective on `ghost`, `link` and `outline`. On
solidorsoftit adds no class at all and logs onewarnOncein development - a prop that silently does nothing is harder to track down than an error. Those two pair their background with their foreground, and dropping the foreground alone would produce combinations that fail contrast. - It is opt-in and changes no default. A
ghostwithoutmutedis still body black, so existing call sites do not move by a pixel. Row actions like "View" or "Reload" are normal emphasis and belong in body black; only genuinely secondary affordances takemuted. - It is not a sixth `tone`.
toneis the semantic-color SSOT shared by 29 components, while muted is an emphasis level, not a hue. Folding it intotonewould forcesolidandsoftto answer "what is a muted fill?" - and abg-mutedfill simply reads as disabled.
The dashed stroke step
In an admin app a dashed border is not decoration, it is a shape with a fixed meaning: "this slot is empty, put something in it". A solid border says "this is a clickable box"; a dashed one says "there is nothing here yet" -- and neither can stand in for the other. Drawing an "add a row" entry point with a solid border turns it from "the table can still grow" into "yet another action button", competing for attention with the real actions on the same row (#270).
Each of the two steps covers one kind of empty slot:
{/* No fill: an upload chip on a table row that has no evidence attached yet */}
<Button variant="outline" size="xs" dashed>Upload</Button>
{/* Tinted: the add-row entry at the end of a table, dashed across the full width */}
<Button variant="soft" dashed block>+ Add a unit manually</Button>outline dashed-- changes the stroke only; the border colour still followstone(tone="danger"stays red). Product code therefore never has to restate the tone table just to get a dashed edge.soft dashed--softhas no border, so this step adds one in the matching colour, giving the complete empty-slot shape: tinted semantic fill, dashed border, semantic text. The border uses 40% ofcurrentColor, so all six tones share one rule; the 40% is deliberate, because a dash as strong as the text reads as a solid box.
Three boundaries:
- Only effective on `outline` and `soft`. On
solid,ghostandlink(including the defaultsolidwhen novariantis passed) it adds no class at all, and development builds log awarnOncenaming it -- same reasoning asmuted, since a silently inert prop is harder to track down than an error.ghosthas no border to convert, and the solid fill ofsolidalready contradicts "this is empty". - Orthogonal to `tone` and `muted`. The dash is a stroke; colour comes from
toneand emphasis frommuted, and the three never overlap:outline dashed mutedis "keep the dashed border, drop the text one level". - Not a sixth variant. As a variant it would have to restate every existing
outlinetone × muted combination in newcompoundVariants, when the only difference between them is oneborder-style. Same reasoning asmuted(#211).
Inheriting the container color with tone="current"
Icon buttons inside a colored card or a colored row should take the color of that container instead of being pulled back to body black. tone="current" means "set no color, leave it to inheritance" (#215):
{/* The arrow is green-700 along with the card, not body black */}
<div className="rounded-md border border-green-400 bg-green-100 p-2 text-green-700">
<span className="text-xs font-medium">Start node</span>
<Button variant="ghost" size="iconXs" tone="current" aria-label="Move up">
<ChevronUp className="size-3" />
</Button>
</div>What separates it from the five semantic steps: those all hand out an absolute color, whereas this expresses "the container already decided the color, the button should keep out of it". A card may be green or blue, and neither deserves its own tone. muted is equally absolute and points the other way (pinned to the secondary gray). The word follows the tone="current" that Spinner already has.
- Only effective on `ghost` and `outline`. On
solid,soft, orlinkthe rendered result is identical to omitting it, plus onewarnOnce.solidandsoftcarry their own background and the foreground has to be paired with it, so inheriting the container color would produce combinations that fail contrast; the resting color oflinkis the brand color, which belongs to the link rather than to the container. - It is opt-in: a
ghostoroutlinewithouttoneis stilltext-foreground. - A call site that writes its own color class (
className="text-red-500") already wins (cnis tailwind-merge).currentcovers the other case: the call site wants to write nothing and just inherit.
Taking the className straight from buttonVariants(...)
The exported function now runs its output through tailwind-merge, so the string can go onto any element as-is:
<a href="/docs" className={buttonVariants({ variant: "ghost", tone: "danger" })}>Delete</a>That was not true in 0.36.0 and earlier: cva only concatenates and never resolves conflicts, so the returned string carried several rules for the same CSS property (base text-foreground alongside text-danger). Dropped onto an <a>, the winner was decided by stylesheet order - 6 of 16 common combinations rendered the wrong color, three of them danger buttons losing their red (#217). The <Button> component never had this problem (it has cn() inside).
Use cn() as usual when composing further classes; merging twice is idempotent.
Size scale
The regular scale has three steps. Every icon size has the same side length as the text size of the same name, so pair icon buttons with the matching text size. Otherwise an attached group (ButtonGroup) shows a visible step at the seam.
| Text size | Height | Font | Matching icon size | Side |
|---|---|---|---|---|
sm | 32px | 14px | iconSm | 32px |
md (default) | 40px | 14px | icon | 40px |
lg | 48px | 16px | iconLg | 48px |
Five more sizes form the dense end. Only `"28"` and `icon28` share a height; every other one is
pinned to its own row scale:
| Dense size | Dimensions | Font | What it pairs with | Where it belongs |
|---|---|---|---|---|
xs | 24px tall | 12px | icon24 | Text buttons in admin toolbars, table rows and panel headers |
"28" | 28px tall | 12px | icon28, Chip md, Sidebar menu item sm | Text buttons on the 28px row scale: filter-pill triggers, secondary actions at the foot of an info card |
iconXs | 20px square | - | Nothing (shorter than every text size) | Icon-only micro actions inside a table row: tree expanders, drag handles |
icon24 | 24px square | - | The xs text size, Tag md, Chip sm | Icon buttons sitting next to xs text buttons |
icon28 | 28px square | - | The "28" text size, Chip md, Sidebar menu item sm | Icon buttons on the 28px row scale, such as the clear button on a filter-pill row |
xs is the smallest text size for dense interfaces. Once a screen carries a dozen actions, sm
(32px / 14px) is one step too large rather than the smallest step, and forcing it into a 24px toolbar
means a stack of override classes that undo the height, padding, font size and radius sm just added.xs already lowers the radius to 4px and tightens the icon gap to 4px, so use it as is instead of
patching it through className.
"28" fills the gap between xs and sm, and its name is a bare number: it differs fromicon28 only by the icon prefix, and a text size has no prefix, so the side length is all that is
left to name it after (the same rule as icon24 / icon28 below). Its font follows xs (12px)
rather than sm: the dense band runs 10 to 12px, and 14px would make every one of these call sites
add text-xs back. Padding (10px) and icon gap (6px) are interpolated by height between xs andsm. The radius stays at --radius, matching icon28, so a 28px text button and a 28px icon button
on the same row also agree on their corners (#228).
The icon size that matches `xs` is `icon24`, not `iconXs`. The one named Xs is 20px: it is another
4px shorter than xs on purpose, because raising it to 24px would push density="compact" table rows
taller, and staying out of the row height is its entire reason to exist. The two names look like a pair
but are two different scales, so take icon24 when you need equal heights (#222).
These two carry numbers rather than t-shirt names because the t-shirt names between xs and sm
were already taken by iconXs (20px), and that size cannot change its side length without silently
flattening every expander that relies on it. The number is the entire meaning of these sizes: they pin
one pixel scale.
The radius follows the side length: icon24 drops to 4px alongside xs and iconXs (a 10px --radius
on a 24px square reads as a disc), while icon28 keeps --radius alongside iconSm (32px).
Dense sizes only mix with dense sizes. iconXs next to xs differs by 4px, which items-center
hides; pairing iconXs with sm or larger opens a gap past 12px, and so does icon24 next to md.
{/* Dense toolbar: xs text buttons next to an icon24 icon button (equal height) */}
<Button size="xs" variant="outline">Record</Button>
<Button size="xs" variant="soft">Filtered</Button>
<Button size="icon24" variant="ghost" muted aria-label="More">
<ChevronRight className="size-4" />
</Button>
{/* The 28px row scale: a text trigger plus a clear button (equal height, matching corners) */}
<Button size="28" variant="outline" tone="neutral" muted>Filter</Button>
<Button size="icon28" variant="outline" muted aria-label="Clear filters">
<X className="size-3.5" />
</Button>
{/* Full-width escape hatch at the foot of a card */}
<Button size="28" variant="outline" muted block>Abort</Button>{/* Correct: matching pair, equal height */}
<ButtonGroup><Button>Save</Button><Button size="icon"><ChevronDown className="size-4" /></Button></ButtonGroup>
{/* Wrong: mismatched sizes, 8px apart */}
<ButtonGroup><Button>Save</Button><Button size="iconSm"><ChevronDown className="size-4" /></Button></ButtonGroup>Usage guidelines
- To avoid unsafe element animation,
rendermode does not apply Motion, so it has no press-scale effect. Color and hover transitions remain, and Button'schildrentake precedence as the visible content. loadingdisables the button automatically; do not adddisabledsolely for the loading state.- Button text cannot be selected (the base class carries
select-none). A button label is a control affordance, not content, and rapid clicking would otherwise make the browser select the word or the whole line. Do not turn text people need to copy into a button. tonechanges meaning, never shape. For a light-background success button usetone="success" variant="soft"instead of overriding the background throughclassName. It is not `variant="outline"`:outlinekeeps the canvas background and only adds a semantic border, so nothing appears to change and the next move is usually to writebg-green-50, exactly what this rule forbids.tone="neutral"insolidis an inverted fill (dark background in light mode, light background in dark mode), not a grey one. A grey fill is nearly indistinguishable fromvariant="outline", which would make the tone pointless.- If an icon wraps away from its label in a custom or effect button, Tailwind Preflight's
svg{display:block}rule is usually the cause. See [[tailwind-preflight-svg-block-breaks-icon-text-in-nonflex-button]]; the wrapper needsinline-flex. Button already handles this internally. - Use
variant="soft"(tinted semantic background with semantic text) for a secondary control that shows an on/off state. Do not override the palette withbg-primary/10 text-primaryinclassName, and do not fall back tosolid: a filled brand block amongh-7toolbar controls outweighs the primary action of the page.softcombines with everytone; the typical form isvariant={isActive ? "soft" : "outline"}. Note that it does not renderaria-pressed, so use Toggle for a real toggle.softfits triggers that merely show something is active, such as a sort chip that opens a menu.
Motion
The base carries press feedback: a slight scale (0.97) on press, with the duration and curve from the motion system's fast step, and both the scale and the transition are dropped under prefers-reduced-motion: reduce -- the library owns that preference, so there is nothing to switch off at the call site.
Two related facts:
- The base holds
pressableClass, nottransition-colors. The two cannot coexist: tailwind-merge treatstransition-*as one conflict group and keeps only the last, discarding the earlier one entirely.pressableClassships a full transition-property list that includes the colour properties precisely so it can replace it. *Adding your own `transition-inclassName` drops the press feedback wholesale** -- if you need to change the transition, restate the scale as well. - Effect buttons (ShimmerButton, RainbowButton, PulsatingButton, RippleButton) use a different base and do not carry this feedback: they animate their own painted backgrounds and own their transition properties.
Related
ShimmerButton · RainbowButton · PulsatingButton · RippleButton · ButtonGroup · SocialButton
Playground
<Button variant="solid" tone="brand" size="md">Hulian Button</Button>