Buttons

Generated from DESIGN_SYSTEM.md

Rules

One primary action per screen. Secondary and tertiary actions are visually subordinate.

No trailing arrows on action-button labels. Get Started β†’ / 보러 κ°€κΈ° β†’ is banned β€” the button shape already signals action; the arrow is redundant decoration. Pure directional glyphs remain legitimate where direction IS the meaning: next/prev pagers, list-row chevron affordances (ChevronRight icon, never a text β†’ appended to a label).

Every button is a role times a size, and nothing else sets its shape. The role says how loud the action is; the size says where it sits. Padding, height, radius and type size come from the size and are never hand-set at the call site, so the same action looks the same on every screen (Predictability, Spatial Memory). Each role has exactly ONE answer per platform; add a role only by replacing what it supersedes in this table, never by appending a row next to it.

RoleWebiOSAndroidUse
Filledbtn btn-filled (variant="filled"): solid brand-primary (blue-600), white labelAppButtonStyle(.primary)FilledButtonTHE one primary action on a screen: form submit, Post, sign in, the empty state's create
Tintedbtn btn-tinted (variant="tinted"): brand tint fill only, no border, no shadow; label blue-700 in light (brand blue on its own tint is 3.66:1 on the page ground, blue-700 is 4.72:1), blue-400 in dark.borderedProminent-tinted / .tint fillFilledTonalButtonSecondary action that is still brand business: Retry, back to list, browse more, see all
Outlinedbtn btn-outlined (variant="outlined"): surface fill + border + shadow-smAppButtonStyle(.secondary) / .borderedOutlinedButtonNeutral action on the page ground: Cancel, Mark resolved, Export
Ghostbtn btn-ghost (variant="ghost"): no fill until hoverAppButtonStyle(.ghost) / .plainTextButtonTertiary, only inside a list, card or toolbar, where the container frames it
(modifier) Destructivebtn-danger on filled or outlined, never a fifth role. Final confirm of an irreversible action (the confirm dialog, the last step of a transfer): btn btn-filled btn-danger (variant="danger", red-600, white label). Trigger or row action that opens a confirm, or sits in a list: btn btn-outlined btn-danger (variant="danger-outlined", red label and border)Role.destructiveerror colorDelete, leave, revoke, transfer

One primary look. The gradient hero (primary-btn, iOS brandActive on buttons) folded into Filled: there is no louder fill above it, and a screen's one decision is marked by being the only filled button, not by a special fill. Filled is blue-600 (brand-primary, #155CFB): white on blue-500 was 3.8:1, under the 4.5:1 AA floor for a 14px label; blue-600 is 5.3:1.

SizeWebSidesHeightType, radiusWhere
smbtn-sm (size="sm")12 (space-3)32 from md up with a fine pointer; 44 on phones and every touch screentext-xs, rounded-lgInside a widget, card, list row, toolbar, inline notice or section header
mdbtn-md (size="md", the default)16 (space-4)44text-sm, rounded-xlPage-level actions including the page header's trailing actions, dialog footers, a page's empty state and error Retry
lgbtn-lg (size="lg")24 (space-6)48text-base, rounded-xlThe one decision of a focused single-task screen (sign in, sign up, consent, pricing, a paywall) and its paired secondary, which is tinted lg

A button's size follows where it sits, never how important it feels: importance is the role. Native maps the same three sizes onto its own control sizes (iOS .controlSize(.small/.regular/.large), Android the M3 small/medium/large button heights), with the 44pt / 48dp touch floor holding at every size.

Icon-only buttons are a square of the same three heights: btn-icon beside the size class (sm 32 on a fine pointer and 44 on touch, md 44, lg 48), <Button size="icon-sm|icon|icon-lg">. Add btn-round (round on <Button>) where the button sits among round controls, such as header chrome beside the avatar or the sign-in button that shares the avatar's slot. The glyph is sized to the button: 16 to 18px in sm, 20px in md and lg. Every icon-only button carries an accessible name (aria-label / accessibilityLabel / contentDescription) that comes from the locale dictionaries like any visible string. An icon inside a labeled button takes no margin of its own: the size's gap spaces it.

Disabled and loading are styled by the element's own disabled / aria-disabled state inside btn (opacity 40, cursor-not-allowed); call sites add no opacity. A disabled <button> keeps pointer events, so a title or tooltip explaining why it is disabled stays reachable; the native disabled attribute already ignores clicks.

Web mechanics. <Button variant size> only maps to them, and a link styled as a button uses the same classes, so the two look identical; <Button href> forwards aria-label and title to the rendered link. Layout utilities (w-full, flex-1, shrink-0, margins, self-*) stay at the call site; padding and height never do, and a role class without btn is the old spelling.

One hover language across every role: the fill deepens, nothing else moves. Press is active:scale-[0.98]. A role that additionally darkens its border or bumps its shadow on hover reads as a different kind of control, which is what makes a set of buttons feel unrelated even when each one is individually fine.

Ghost is container-dependent, the others are not. Ghost has no fill and no border, so it only reads where something else supplies the frame. On the page ground it is invisible at rest. Neutral action on a page β†’ outlined, always.

Rules:

  • Minimum tap target: 44x44pt (iOS) / 48x48dp (Android) / 44px web on touch, at every size
  • One filled button per screen; a second action beside it is tinted, outlined or ghost
  • Red fill only on the final confirm of an irreversible action; everywhere else destructive is the outlined form

Primary fill rule (every platform): the primary button is a solid brandPrimary fill on web, iOS and Android alike, the one primary look. The brandActive gradient is retired for buttons. iOS uses AppButtonStyle(.primary), Android FilledButton with primary.

  • AppTheme.Gradients.brandActive stays for non-button brand surfaces (hero art, progress accents); on a button it is migration debt, not precedent
  • Exception, unchanged: progress bars, pagination dots, tint overlays use flat brandPrimary

Liquid glass style (iOS) β€” floating nav buttons: Every button that floats above a gradient or image background (nav bar, home hero, floating overlay) must use the GlassBackground modifier, not a flat solid fill.

  • βœ… AppNavCircleButton β€” use for all icon-only nav-bar slots (leading and trailing)
  • βœ… LiquidGlassButton β€” use for capsule-shaped floating actions
  • βœ… Profile avatar β€” same glass circle, progress ring overlaid on top
  • ❌ .background(AppTheme.Colors.surfaceSecondary) on any button that sits over a gradient β€” renders flat/opaque; use .glassBackground() instead
  • Shadow standard: Color.black.opacity(0.12), radius: 6, y: 3 for circle buttons; opacity(0.15) for capsule buttons (LiquidGlassButton). Do not reduce below these values β€” lighter shadows make buttons disappear against pale backgrounds.

Live specimens

Roles Γ— sizesOn the page ground. The role sets how loud the action is; the size follows where it sits.

filled

btn btn-filled btn-sm
btn btn-filled btn-md
btn btn-filled btn-lg

tinted

btn btn-tinted btn-sm
btn btn-tinted btn-md
btn btn-tinted btn-lg

outlined

btn btn-outlined btn-sm
btn btn-outlined btn-md
btn btn-outlined btn-lg
Ghost, inside a cardGhost has no fill, so it only reads where a container frames it.

ghost

btn btn-ghost btn-sm
btn btn-ghost btn-md
btn btn-ghost btn-lg
DestructiveRed fill for the final confirm only; the outlined form everywhere else.
btn btn-filled btn-danger btn-md
btn btn-outlined btn-danger btn-md
Icon onlyA square of the same three heights; round among round controls.
btn btn-outlined btn-icon btn-sm
btn btn-outlined btn-icon btn-md
btn btn-outlined btn-icon btn-lg
btn btn-outlined btn-icon btn-sm btn-round
btn btn-outlined btn-icon btn-md btn-round
btn btn-outlined btn-icon btn-lg btn-round
Disabled and loading
disabled
loading