NS Design System
v3.0.0 · 257 tokens

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.

// Course Apex basics

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.

Browse courses Back
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