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.
| Role | Web | iOS | Android | Use |
|---|---|---|---|---|
| Filled | btn btn-filled (variant="filled"): solid brand-primary (blue-600), white label | AppButtonStyle(.primary) | FilledButton | THE one primary action on a screen: form submit, Post, sign in, the empty state's create |
| Tinted | btn 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 fill | FilledTonalButton | Secondary action that is still brand business: Retry, back to list, browse more, see all |
| Outlined | btn btn-outlined (variant="outlined"): surface fill + border + shadow-sm | AppButtonStyle(.secondary) / .bordered | OutlinedButton | Neutral action on the page ground: Cancel, Mark resolved, Export |
| Ghost | btn btn-ghost (variant="ghost"): no fill until hover | AppButtonStyle(.ghost) / .plain | TextButton | Tertiary, only inside a list, card or toolbar, where the container frames it |
| (modifier) Destructive | btn-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.destructive | error color | Delete, 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.
| Size | Web | Sides | Height | Type, radius | Where |
|---|---|---|---|---|---|
| sm | btn-sm (size="sm") | 12 (space-3) | 32 from md up with a fine pointer; 44 on phones and every touch screen | text-xs, rounded-lg | Inside a widget, card, list row, toolbar, inline notice or section header |
| md | btn-md (size="md", the default) | 16 (space-4) | 44 | text-sm, rounded-xl | Page-level actions including the page header's trailing actions, dialog footers, a page's empty state and error Retry |
| lg | btn-lg (size="lg") | 24 (space-6) | 48 | text-base, rounded-xl | The 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.brandActivestays 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: 3for 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
filled
btn btn-filled btn-smbtn btn-filled btn-mdbtn btn-filled btn-lgtinted
btn btn-tinted btn-smbtn btn-tinted btn-mdbtn btn-tinted btn-lgoutlined
btn btn-outlined btn-smbtn btn-outlined btn-mdbtn btn-outlined btn-lgghost
btn btn-ghost btn-smbtn btn-ghost btn-mdbtn btn-ghost btn-lgbtn btn-filled btn-danger btn-mdbtn btn-outlined btn-danger btn-mdbtn btn-outlined btn-icon btn-smbtn btn-outlined btn-icon btn-mdbtn btn-outlined btn-icon btn-lgbtn btn-outlined btn-icon btn-sm btn-roundbtn btn-outlined btn-icon btn-md btn-roundbtn btn-outlined btn-icon btn-lg btn-rounddisabledloading