14Button
The action primitive. Solid brand fill is reserved for primary — the one thing to click on a screen. Everything else is a hairline. Press is an instant opacity dim: no bounce, no lift.
Use it for
- Submitting, confirming, starting — real actions
- One primary per screen; outline for the secondary
- quiet for the lowest-stakes action beside a confirm
Not for
- Navigation that just goes somewhere — use a link; a button that navigates lies to screen readers
- Two primaries side by side — one of them is wrong
- Toggling state — use Switch or a pressed button-group member
Emphasis
primary → outline → quiet is the whole emphasis scale.
markup
<button class="ns-btn ns-btn--primary">Start learning</button>
<button class="ns-btn ns-btn--outline">View syllabus</button>
<button class="ns-btn ns-btn--quiet">Cancel</button>Sizes
markup
<button class="ns-btn ns-btn--primary ns-btn--sm">Small</button>
<button class="ns-btn ns-btn--primary">Medium</button>
<button class="ns-btn ns-btn--primary ns-btn--lg">Large</button>With icon
Phosphor glyphs; icon-only needs aria-label.
markup
<button class="ns-btn ns-btn--primary"><i class="ph ph-play" aria-hidden="true"></i> Resume lesson</button>
<button class="ns-btn ns-btn--outline ns-btn--icon" aria-label="Settings"><i class="ph ph-gear" aria-hidden="true"></i></button>States
markup
<button class="ns-btn ns-btn--primary" disabled>Disabled</button>
<button class="ns-btn ns-btn--primary" data-loading="true">Saving</button>Destructive
Outline by default — delete is almost never the primary action. The solid variant exists only for the confirm button inside a delete dialog.
markup
<button class="ns-btn ns-btn--danger">Delete course</button>
<button class="ns-btn ns-btn--danger-solid">Yes, delete it</button>On dark surfaces
white/ghost are for hero bands only — invisible on light, on purpose.
markup
<button class="ns-btn ns-btn--white">Start learning</button>
<button class="ns-btn ns-btn--ghost">Watch demo</button>Button group
A segmented control; aria-pressed marks the active member.
markup
<div class="ns-btn-group">
<button class="ns-btn ns-btn--outline ns-btn--sm" aria-pressed="true">List</button>
<button class="ns-btn ns-btn--outline ns-btn--sm">Cards</button>
<button class="ns-btn ns-btn--outline ns-btn--sm">Timeline</button>
</div>Size scale
Four steps, and the default is deliberately not the biggest one. A button lives INSIDE something — a card, a row, a toolbar — and the most common design-system bug is a default button set at body size and 44px tall, which is visually larger than the card title above it. The default carries a --size-small label at --target-comfy (40px): the touch target is untouched, the visual bulk is not. Height and type size are set independently on purpose — height is the accessibility property, size is the typographic one.
markup
<button class="ns-btn ns-btn--primary ns-btn--xl">Extra large</button>
<button class="ns-btn ns-btn--primary ns-btn--lg">Large</button>
<button class="ns-btn ns-btn--primary">Default</button>
<button class="ns-btn ns-btn--primary ns-btn--sm">Small</button>
<button class="ns-btn ns-btn--primary ns-btn--xs">Extra small</button>In proportion
The same three buttons inside a card, which is where the sizing question actually gets decided. --xl exists for exactly one thing: the single primary action in a hero. Anywhere else it means the screen has a hierarchy problem.
The platform's own language, from zero to first deploy.
markup
<div class="ns-card" style="max-inline-size:20rem">
<div class="ns-card__body">
<span class="ns-card__kicker">// Course</span>
<span class="ns-card__title">Apex basics</span>
<p class="ns-card__text">The platform's own language, from zero to first deploy.</p>
<div style="display:flex;gap:var(--space-2);margin-block-start:var(--space-2)">
<button class="ns-btn ns-btn--primary ns-btn--sm">Start</button>
<button class="ns-btn ns-btn--outline ns-btn--sm">Syllabus</button>
<button class="ns-btn ns-btn--quiet ns-btn--icon ns-btn--sm" aria-label="Save"><i class="ph ph-bookmark-simple" aria-hidden="true"></i></button>
</div>
</div>
</div>Soft
The step between outline and primary: a brand tint, no solid fill. For a secondary action that still has to be found — Preview, Add to list — where an outline disappears in a busy card. It does not break the one-solid-button rule, because it is not solid.
markup
<button class="ns-btn ns-btn--primary">Enrol now</button>
<button class="ns-btn ns-btn--soft">Preview this course</button>
<button class="ns-btn ns-btn--outline">Syllabus</button>
<button class="ns-btn ns-btn--quiet">Not now</button>Full width
--block is always full width; --block-sm is full width on a phone and intrinsic above it, which is the right default for a form's submit and a buy box's CTA — and the media query every product ends up writing by hand.
markup
<div style="max-inline-size:18rem;display:grid;gap:var(--space-2)">
<button class="ns-btn ns-btn--primary ns-btn--block">Enrol — $49</button>
<button class="ns-btn ns-btn--outline ns-btn--block">Add to wishlist</button>
</div>Pill & segmented
Pill geometry is a deliberate exception — Principle 4 reserves it for true pills — allowed where the button IS a chip: filter and toggle rows. --block on a group makes the members share the width equally.
markup
<button class="ns-btn ns-btn--outline ns-btn--sm ns-btn--pill" aria-pressed="true">All</button>
<button class="ns-btn ns-btn--outline ns-btn--sm ns-btn--pill" aria-pressed="false">Admin</button>
<button class="ns-btn ns-btn--outline ns-btn--sm ns-btn--pill" aria-pressed="false">Developer</button>
<div class="ns-btn-group ns-btn-group--pill" style="margin-inline-start:var(--space-4)">
<button class="ns-btn ns-btn--outline ns-btn--sm" aria-pressed="true">Grid</button>
<button class="ns-btn ns-btn--outline ns-btn--sm" aria-pressed="false">List</button>
</div>Toggle & swap
aria-pressed is the state, so the visual and the announced state cannot drift apart. --swap stacks both labels in one grid cell, so the button does not change width when it flips — which is what stops a toolbar twitching.
markup
<button class="ns-btn ns-btn--outline ns-btn--swap" aria-pressed="false" onclick="this.setAttribute('aria-pressed', this.getAttribute('aria-pressed')==='true'?'false':'true')">
<span data-when="off"><i class="ph ph-heart" aria-hidden="true"></i> Save</span>
<span data-when="on"><i class="ph ph-heart" aria-hidden="true"></i> Saved</span>
</button>
<button class="ns-btn ns-btn--outline" aria-pressed="true"><i class="ph ph-bookmark-simple" aria-hidden="true"></i> Bookmarked</button>Motion, count, shortcut
The one piece of motion a button is allowed: a trailing arrow may travel two pixels in the direction it points. That is the arrow doing its job — not the button pretending to be a physical object, which Principle 5 rules out. The count and the ⌘K hint are mono and dimmed, so they read as metadata rather than part of the action's name.
markup
<a class="ns-btn ns-btn--primary" href="#0">Browse courses <i class="ph ph-arrow-right ns-btn__arrow" aria-hidden="true"></i></a>
<a class="ns-btn ns-btn--outline ns-btn--back" href="#0"><i class="ph ph-arrow-left ns-btn__arrow" aria-hidden="true"></i> Back</a>
<button class="ns-btn ns-btn--outline">Filters <span class="ns-btn__count">3</span></button>
<button class="ns-btn ns-btn--quiet">Search <kbd class="ns-btn__kbd">⌘K</kbd></button>Accessibility contract
- Icon-only buttons need aria-label
- Loading keeps the label in the DOM (dimmed) so width and accessible name survive the wait
- disabled removes it from the tab order; aria-disabled keeps link-shaped buttons announceable