hermes-hub/.agents/skills/frontend-design/product-ui-patterns.md

35 KiB
Raw Permalink Blame History

Product UI Patterns — Linear-style components, in code

A practical deep-dive into the components that make Linear-style product UI feel considered. Each pattern includes concrete HTML + CSS, all states, and what to avoid. Use this when building product chrome (sidebar, command palette, lists, modals) — not for marketing pages.


How to use this file

This file is code-first. When you need to build a sidebar, command palette, segmented control, or status indicator for a Linear-style product, come here.

Each component has:

  1. When to use it
  2. Anatomy (what it's made of)
  3. HTML (semantic markup)
  4. CSS (with tokens, all states)
  5. States covered (default, hover, focus, active, disabled, loading)
  6. Anti-patterns (what breaks the pattern)

All CSS assumes the Linear-style dark token system (see minimal-ui-patterns.md §1 for tokens). Adapt colors to your palette.


1. Sidebar (navigation)

Used in: Linear, Notion, Figma, most SaaS apps.

Anatomy

  • Container with hairline right border
  • Section labels (in caps mono, wide tracking)
  • List of nav items (icon + label)
  • Active state (background tint or left accent border)
  • Hover state (subtle background tint)
  • Footer (user avatar, workspace switcher)

HTML

<aside class="sidebar" aria-label="Primary navigation">
  <div class="sidebar__inner">
    <header class="sidebar__head">
      <button class="workspace-switch" aria-label="Switch workspace">
        <span class="workspace-mark"></span>
        <span class="workspace-name">Acme</span>
        <span class="workspace-chevron"></span>
      </button>
    </header>

    <nav class="sidebar__nav">
      <div class="sidebar__section">
        <p class="sidebar__label">Workspace</p>
        <ul role="list">
          <li>
            <a href="#" class="sidebar__item sidebar__item--active">
              <svg class="sidebar__icon" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
                <rect x="2" y="2" width="12" height="12" rx="2" fill="none" stroke="currentColor" stroke-width="1.5"/>
              </svg>
              <span>Inbox</span>
              <span class="sidebar__count">12</span>
            </a>
          </li>
          <li>
            <a href="#" class="sidebar__item">
              <svg class="sidebar__icon" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
                <circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" stroke-width="1.5"/>
              </svg>
              <span>My issues</span>
            </a>
          </li>
        </ul>
      </div>

      <div class="sidebar__section">
        <p class="sidebar__label">Your teams</p>
        <ul role="list">
          <li>
            <a href="#" class="sidebar__item">
              <span class="sidebar__team-mark"></span>
              <span>Engineering</span>
            </a>
          </li>
        </ul>
      </div>
    </nav>

    <footer class="sidebar__foot">
      <button class="user-menu" aria-label="User menu">
        <span class="avatar">JD</span>
        <span class="user-menu__name">Jane Doe</span>
      </button>
    </footer>
  </div>
</aside>

CSS

.sidebar {
  width: 240px;
  height: 100vh;
  background: var(--surface-1);
  border-right: 1px solid var(--hairline);
  display: flex;
  flex-direction: column;
  position: sticky;
  top: 0;
}

.sidebar__inner {
  display: flex;
  flex-direction: column;
  height: 100%;
  padding: var(--sp-3);
}

.sidebar__head {
  padding: var(--sp-2) var(--sp-3);
  margin-bottom: var(--sp-3);
}

.workspace-switch {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  width: 100%;
  padding: var(--sp-2) var(--sp-3);
  border-radius: var(--r-sm);
  font-size: var(--text-sm);
  font-weight: 500;
  color: var(--ink);
  transition: background 120ms ease;
}
.workspace-switch:hover { background: var(--surface-2); }
.workspace-switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }

.workspace-mark { color: var(--accent); }
.workspace-name { flex: 1; text-align: left; }
.workspace-chevron { color: var(--ink-muted); font-size: 11px; }

.sidebar__nav { flex: 1; overflow-y: auto; }

.sidebar__section { margin-bottom: var(--sp-5); }

.sidebar__label {
  font-family: var(--font-mono);
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: 0.1em;
  color: var(--ink-subtle);
  padding: 0 var(--sp-3);
  margin-bottom: var(--sp-2);
  font-weight: 500;
}

.sidebar__section ul { list-style: none; margin: 0; padding: 0; }

.sidebar__item {
  display: flex;
  align-items: center;
  gap: var(--sp-3);
  padding: 6px var(--sp-3);
  border-radius: var(--r-sm);
  font-size: var(--text-sm);
  color: var(--ink-muted);
  text-decoration: none;
  transition: background 100ms ease, color 100ms ease;
}

.sidebar__item:hover {
  background: var(--surface-2);
  color: var(--ink);
}

.sidebar__item:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
}

.sidebar__item--active {
  background: var(--accent-soft);
  color: var(--ink);
  font-weight: 500;
}

.sidebar__icon { color: currentColor; flex-shrink: 0; }

.sidebar__count {
  margin-left: auto;
  font-family: var(--font-mono);
  font-size: 11px;
  padding: 1px 6px;
  border-radius: 3px;
  background: var(--surface-2);
  color: var(--ink-muted);
}

.sidebar__team-mark {
  font-size: 11px;
  color: var(--ink-muted);
  width: 16px;
  text-align: center;
}

.sidebar__foot {
  border-top: 1px solid var(--hairline);
  padding-top: var(--sp-3);
  margin-top: var(--sp-3);
}

.user-menu {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  width: 100%;
  padding: var(--sp-2) var(--sp-3);
  border-radius: var(--r-sm);
  font-size: var(--text-sm);
  color: var(--ink);
  transition: background 120ms ease;
}
.user-menu:hover { background: var(--surface-2); }

.avatar {
  width: 20px;
  height: 20px;
  border-radius: 50%;
  background: var(--accent);
  color: var(--surface);
  font-size: 10px;
  font-weight: 600;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
}

.user-menu__name { flex: 1; text-align: left; }

Anti-patterns

  • Sidebar > 280px wide (consumes too much horizontal space)
  • Active state as bold text only (no background or border)
  • Icon-only nav without tooltips
  • Section labels in regular case (must be mono caps with tracking)
  • Hover state that shifts layout (causes jank)

2. Command palette (⌘K)

Used in: Linear, GitHub, Vercel, Raycast, every modern SaaS.

Anatomy

  • Centered modal, max-width 560640px
  • Input field at top, large, no border
  • List of results below, keyboard-driven
  • Keyboard shortcuts visible on right side of each row
  • Categories separated by hairline + section label
  • Empty state for "no results"

HTML

<div class="command-palette" role="dialog" aria-modal="true" aria-labelledby="cp-title">
  <div class="command-palette__backdrop"></div>
  <div class="command-palette__panel">
    <h2 id="cp-title" class="sr-only">Command palette</h2>

    <div class="cp-input-wrap">
      <svg class="cp-search-icon" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
        <circle cx="7" cy="7" r="5" fill="none" stroke="currentColor" stroke-width="1.5"/>
        <line x1="11" y1="11" x2="14" y2="14" stroke="currentColor" stroke-width="1.5"/>
      </svg>
      <input
        type="text"
        class="cp-input"
        placeholder="Search or jump to anywhere…"
        aria-label="Search"
        autofocus
      >
      <kbd class="cp-esc">esc</kbd>
    </div>

    <div class="cp-results">
      <div class="cp-group">
        <p class="cp-group__label">Recent</p>
        <ul role="list">
          <li class="cp-result cp-result--selected">
            <span class="cp-result__icon"></span>
            <span class="cp-result__main">
              <span class="cp-result__title">ENG-1421</span>
              <span class="cp-result__sub">Reduce LCP on checkout page</span>
            </span>
            <span class="cp-result__meta">2h ago</span>
            <kbd class="cp-result__kbd"></kbd>
          </li>
          <!-- More results -->
        </ul>
      </div>

      <div class="cp-group">
        <p class="cp-group__label">Actions</p>
        <ul role="list">
          <li class="cp-result">
            <span class="cp-result__icon">+</span>
            <span class="cp-result__main">
              <span class="cp-result__title">Create new issue</span>
            </span>
            <kbd class="cp-result__kbd">C</kbd>
          </li>
        </ul>
      </div>
    </div>

    <footer class="cp-foot">
      <span><kbd></kbd><kbd></kbd> Navigate</span>
      <span><kbd></kbd> Select</span>
      <span><kbd>esc</kbd> Close</span>
    </footer>
  </div>
</div>

CSS

.command-palette {
  position: fixed;
  inset: 0;
  z-index: 100;
  display: flex;
  align-items: flex-start;
  justify-content: center;
  padding-top: 12vh;
}

.command-palette__backdrop {
  position: absolute;
  inset: 0;
  background: rgba(0, 0, 0, 0.5);
  backdrop-filter: blur(2px);
}

.command-palette__panel {
  position: relative;
  width: 100%;
  max-width: 600px;
  background: var(--surface-1);
  border: 1px solid var(--hairline-strong);
  border-radius: var(--r-lg);
  box-shadow: 0 24px 48px rgba(0, 0, 0, 0.5);
  overflow: hidden;
  animation: cp-in 140ms ease-out;
}

@keyframes cp-in {
  from { opacity: 0; transform: scale(0.98) translateY(-8px); }
  to   { opacity: 1; transform: scale(1) translateY(0); }
}

.cp-input-wrap {
  display: flex;
  align-items: center;
  gap: var(--sp-3);
  padding: var(--sp-4) var(--sp-5);
  border-bottom: 1px solid var(--hairline);
}

.cp-search-icon { color: var(--ink-muted); flex-shrink: 0; }

.cp-input {
  flex: 1;
  font-size: var(--text-md);
  color: var(--ink);
  background: transparent;
  border: 0;
  outline: 0;
}
.cp-input::placeholder { color: var(--ink-subtle); }

.cp-esc {
  font-family: var(--font-mono);
  font-size: 11px;
  padding: 2px 6px;
  border-radius: 3px;
  background: var(--surface-2);
  border: 1px solid var(--hairline);
  color: var(--ink-muted);
}

.cp-results {
  max-height: 60vh;
  overflow-y: auto;
  padding: var(--sp-3);
}

.cp-group { margin-bottom: var(--sp-3); }
.cp-group:last-child { margin-bottom: 0; }

.cp-group__label {
  font-family: var(--font-mono);
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: 0.1em;
  color: var(--ink-subtle);
  padding: var(--sp-2) var(--sp-3);
  margin: 0;
  font-weight: 500;
}

.cp-group ul { list-style: none; margin: 0; padding: 0; }

.cp-result {
  display: flex;
  align-items: center;
  gap: var(--sp-3);
  padding: var(--sp-3);
  border-radius: var(--r-sm);
  cursor: pointer;
  font-size: var(--text-sm);
  transition: background 80ms ease;
}

.cp-result:hover { background: var(--surface-2); }
.cp-result--selected { background: var(--accent-soft); }
.cp-result--selected .cp-result__title { color: var(--accent); }

.cp-result__icon {
  width: 20px;
  height: 20px;
  border-radius: 3px;
  background: var(--surface-2);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font-size: 12px;
  color: var(--ink-muted);
  flex-shrink: 0;
}

.cp-result__main { flex: 1; display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.cp-result__title { color: var(--ink); font-weight: 500; }
.cp-result__sub { color: var(--ink-muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

.cp-result__meta { font-family: var(--font-mono); font-size: 11px; color: var(--ink-subtle); }

.cp-result__kbd {
  font-family: var(--font-mono);
  font-size: 11px;
  padding: 2px 6px;
  border-radius: 3px;
  background: var(--surface-2);
  border: 1px solid var(--hairline);
  color: var(--ink-muted);
}

.cp-foot {
  display: flex;
  gap: var(--sp-5);
  padding: var(--sp-3) var(--sp-5);
  border-top: 1px solid var(--hairline);
  font-size: 11px;
  color: var(--ink-subtle);
}

.cp-foot kbd {
  font-family: var(--font-mono);
  font-size: 10px;
  padding: 1px 5px;
  border-radius: 3px;
  background: var(--surface-2);
  border: 1px solid var(--hairline);
  margin-right: 4px;
}

Behavior (JS sketch)

// Open on ⌘K / Ctrl+K
// Close on Esc or click outside
// Up/Down to navigate
// Enter to select
// Auto-focus input on open
// Reset query on close

Anti-patterns

  • Backdrop too dark (0.7+) — feels oppressive
  • Animation > 200ms (feels slow for power-user feature)
  • No keyboard navigation (this is a keyboard-first feature)
  • Hidden close button (must be discoverable)
  • Results without categories (hard to scan)
  • No "no results" empty state

3. Empty states

Used in: Every list view, dashboard, settings page.

Anatomy

  • Centered or left-aligned container
  • Single illustration or geometric icon (subtle, restrained)
  • One headline explaining what should be here
  • One sentence of context (optional)
  • One primary action button
  • Optional secondary link (e.g., "Read docs")

HTML

<div class="empty-state" role="status">
  <div class="empty-state__icon" aria-hidden="true">
    <!-- Geometric SVG, ~64px -->
    <svg width="64" height="64" viewBox="0 0 64 64">
      <rect x="8" y="8" width="48" height="48" rx="6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-dasharray="4 4"/>
      <line x1="20" y1="24" x2="44" y2="24" stroke="currentColor" stroke-width="1.5"/>
      <line x1="20" y1="32" x2="36" y2="32" stroke="currentColor" stroke-width="1.5"/>
      <line x1="20" y1="40" x2="40" y2="40" stroke="currentColor" stroke-width="1.5"/>
    </svg>
  </div>

  <h3 class="empty-state__title">No issues yet</h3>
  <p class="empty-state__desc">Issues track the work your team needs to do. Create your first one to get started.</p>

  <div class="empty-state__actions">
    <button class="btn btn--primary">Create issue</button>
    <a href="#" class="empty-state__link">Read the docs →</a>
  </div>
</div>

CSS

.empty-state {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  padding: var(--sp-9) var(--sp-5);
  max-width: 400px;
  margin: 0 auto;
}

.empty-state__icon {
  color: var(--ink-subtle);
  margin-bottom: var(--sp-5);
}

.empty-state__title {
  font-size: var(--text-lg);
  font-weight: 600;
  color: var(--ink);
  margin: 0 0 var(--sp-2) 0;
  letter-spacing: -0.01em;
}

.empty-state__desc {
  font-size: var(--text-sm);
  color: var(--ink-muted);
  line-height: 1.5;
  margin: 0 0 var(--sp-5) 0;
}

.empty-state__actions {
  display: flex;
  align-items: center;
  gap: var(--sp-4);
}

.empty-state__link {
  font-size: var(--text-sm);
  color: var(--ink-muted);
  text-decoration: none;
  border-bottom: 1px solid var(--hairline);
  padding-bottom: 1px;
  transition: color 120ms ease, border-color 120ms ease;
}

.empty-state__link:hover {
  color: var(--accent);
  border-color: var(--accent);
}

Anti-patterns

  • Stock illustration of confused person
  • Sad emoji
  • Two action buttons competing
  • Empty state that says "No data" (meaningless)
  • Centered illustration that's huge (200px+) — feels desperate

4. List items

Used in: Inbox, tables-of-contents, search results, settings lists.

Anatomy

  • 4048px row height
  • Single line of primary content
  • Optional secondary line (subdued)
  • Left: icon or status indicator
  • Right: metadata or actions
  • Hairline divider between rows
  • Hover: subtle background tint
  • Selected: accent-tinted background

HTML

<ul class="list" role="list">
  <li class="list__item">
    <a href="#" class="list__link">
      <span class="list__status list__status--in-progress" aria-label="In progress"></span>
      <span class="list__main">
        <span class="list__id">ENG-1421</span>
        <span class="list__title">Reduce LCP on checkout page</span>
      </span>
      <span class="list__priority" aria-label="High priority">
        <svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true">
          <path d="M7 2 L12 12 L2 12 Z" fill="currentColor"/>
        </svg>
      </span>
      <span class="list__assignee">
        <span class="avatar avatar--xs">JD</span>
      </span>
      <time class="list__date" datetime="2026-04-12">Apr 12</time>
    </a>
  </li>
  <!-- More items -->
</ul>

CSS

.list {
  list-style: none;
  margin: 0;
  padding: 0;
  border-top: 1px solid var(--hairline);
}

.list__item {
  border-bottom: 1px solid var(--hairline);
}

.list__link {
  display: grid;
  grid-template-columns: 12px minmax(0, 1fr) auto auto auto;
  gap: var(--sp-3);
  align-items: center;
  padding: var(--sp-3) var(--sp-4);
  font-size: var(--text-sm);
  color: var(--ink);
  text-decoration: none;
  transition: background 100ms ease;
}

.list__link:hover {
  background: var(--surface-2);
}

.list__link:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
}

.list__status {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  flex-shrink: 0;
}

.list__status--todo        { background: var(--ink-subtle); }
.list__status--in-progress { background: var(--warn); }
.list__status--done        { background: var(--good); }
.list__status--blocked     { background: var(--bad); }

.list__main {
  display: flex;
  align-items: baseline;
  gap: var(--sp-3);
  min-width: 0;
}

.list__id {
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  color: var(--ink-subtle);
  flex-shrink: 0;
}

.list__title {
  color: var(--ink);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.list__priority {
  color: var(--warn);
  display: inline-flex;
}

.list__assignee { display: inline-flex; }

.avatar--xs {
  width: 18px;
  height: 18px;
  font-size: 9px;
}

.list__date {
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  color: var(--ink-subtle);
  white-space: nowrap;
}

Anti-patterns

  • Row height > 56px (consumes too much space)
  • Multiple status indicators stacked
  • Right-side metadata that wraps
  • Hover that shifts content (causes jank)
  • Border between every row + border-radius (conflicting)

5. Status indicators (badges, dots, pills)

Used in: Issue trackers, project management, notifications.

Anatomy — three types

Dot (8px circle, semantic color)

  • Inbox counts, status indicators, presence
  • One color, no text

Badge (rounded rectangle with text)

  • Status labels ("In review", "Blocked")
  • Small (1112px text), padded

Pill (full-rounded rectangle with text)

  • Tags, categories
  • Slightly larger than badge

HTML + CSS

<!-- Dot -->
<span class="status-dot status-dot--in-progress" aria-label="In progress"></span>

<!-- Badge -->
<span class="status-badge status-badge--review">In review</span>

<!-- Pill / tag -->
<span class="tag">Engineering</span>
/* Dot */
.status-dot {
  display: inline-block;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  flex-shrink: 0;
}
.status-dot--todo        { background: var(--ink-subtle); }
.status-dot--in-progress { background: var(--warn); }
.status-dot--done        { background: var(--good); }
.status-dot--blocked     { background: var(--bad); }

/* Badge */
.status-badge {
  display: inline-flex;
  align-items: center;
  height: 20px;
  padding: 0 8px;
  border-radius: var(--r-sm);
  font-size: 11px;
  font-weight: 500;
  letter-spacing: 0;
  white-space: nowrap;
}

.status-badge--todo        { background: var(--surface-2); color: var(--ink-muted); }
.status-badge--in-progress { background: rgba(251, 191, 36, 0.15); color: var(--warn); }
.status-badge--review      { background: rgba(99, 91, 255, 0.15); color: var(--accent); }
.status-badge--done        { background: rgba(76, 183, 130, 0.15); color: var(--good); }
.status-badge--blocked     { background: rgba(248, 113, 113, 0.15); color: var(--bad); }

/* Pill / tag */
.tag {
  display: inline-flex;
  align-items: center;
  height: 22px;
  padding: 0 10px;
  border-radius: 9999px;     /* pill is OK for tags — they're meant to be pills */
  font-size: 12px;
  font-weight: 500;
  background: var(--surface-2);
  color: var(--ink-muted);
  border: 1px solid var(--hairline);
}

Anti-patterns

  • Using pill on every interactive element (only for tags/categories)
  • Too many color states (stick to 34 semantic colors)
  • Badges with no text (just a dot) — ambiguous
  • Text inside badges too long (truncate or shorten label)

6. Segmented control

Used in: Time range selectors (1h / 24h / 7d / 30d), view toggles.

Anatomy

  • Container with hairline border, subtle background
  • Buttons inside, equal-width or content-width
  • Active button: surface background, slight shadow
  • Hover: ink color change
  • Mono font for time labels (technical feel)

HTML

<div class="segmented" role="tablist" aria-label="Time range">
  <button role="tab" aria-selected="false" class="segmented__btn">1h</button>
  <button role="tab" aria-selected="false" class="segmented__btn">24h</button>
  <button role="tab" aria-selected="true"  class="segmented__btn segmented__btn--active">7d</button>
  <button role="tab" aria-selected="false" class="segmented__btn">30d</button>
</div>

CSS

.segmented {
  display: inline-flex;
  background: var(--surface-2);
  border: 1px solid var(--hairline);
  border-radius: var(--r-sm);
  padding: 2px;
  gap: 0;
}

.segmented__btn {
  padding: 4px 10px;
  font-family: var(--font-mono);
  font-size: 11px;
  letter-spacing: 0.04em;
  color: var(--ink-muted);
  border-radius: 3px;
  transition: color 120ms ease, background 120ms ease;
  white-space: nowrap;
}

.segmented__btn:hover { color: var(--ink); }

.segmented__btn--active {
  background: var(--surface);
  color: var(--ink);
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}

.segmented__btn:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}

Anti-patterns

  • Active state as bold text only (no background change)
  • Buttons without aria-selected (a11y failure)
  • Sans font for time labels (mono reads as more precise)
  • Animated transitions on click (sluggish for power users)

7. Tables (data tables)

Used in: Settings, admin panels, lists with structured data.

Anatomy

  • Header row: caps mono, muted, smaller text
  • Body rows: 48px height, hairline bottom border
  • Numbers: mono font, tabular figures, right-aligned
  • Text columns: left-aligned
  • Hover row: subtle background tint
  • Action column: right-aligned, icon buttons or text links

HTML

<table class="data-table">
  <thead>
    <tr>
      <th>Name</th>
      <th>Role</th>
      <th>Last active</th>
      <th class="data-table__num">Sessions</th>
      <th class="data-table__actions"></th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Jane Doe</td>
      <td>Admin</td>
      <td>2 hours ago</td>
      <td class="data-table__num">1,247</td>
      <td class="data-table__actions">
        <button class="icon-btn" aria-label="Edit Jane Doe">
          <svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true">
            <path d="M2 10 L2 12 L4 12 L10 6 L8 4 L2 10 Z" fill="currentColor"/>
          </svg>
        </button>
      </td>
    </tr>
  </tbody>
</table>

CSS

.data-table {
  width: 100%;
  border-collapse: collapse;
  font-size: var(--text-sm);
}

.data-table thead th {
  text-align: left;
  font-family: var(--font-mono);
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: 0.1em;
  color: var(--ink-muted);
  font-weight: 500;
  padding: var(--sp-3) var(--sp-4);
  border-bottom: 1px solid var(--hairline);
}

.data-table tbody td {
  padding: var(--sp-4);
  border-bottom: 1px solid var(--hairline);
  color: var(--ink);
  vertical-align: middle;
}

.data-table tbody tr {
  transition: background 80ms ease;
}

.data-table tbody tr:hover {
  background: var(--surface-2);
}

.data-table__num {
  text-align: right;
  font-family: var(--font-mono);
  font-feature-settings: 'tnum' 1;
}

.data-table__actions {
  text-align: right;
  width: 40px;
}

.icon-btn {
  width: 28px;
  height: 28px;
  border-radius: var(--r-sm);
  color: var(--ink-muted);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  transition: background 120ms ease, color 120ms ease;
}

.icon-btn:hover {
  background: var(--surface-2);
  color: var(--ink);
}

.icon-btn:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}

Anti-patterns

  • Full grid of borders (looks like Excel)
  • Centered data cells (left-align text, right-align numbers)
  • Wrapping headers (use shorter labels)
  • Inconsistent row heights
  • Sortable columns without visual indicator

8. Modal / dialog

Used in: Create flows, confirmations, settings panels.

Anatomy

  • Centered, max-width 480560px (forms), 720px+ (content)
  • Backdrop: rgba(0,0,0, 0.40.6)
  • Surface: elevated panel
  • Border-radius: 812px
  • Padding: 2432px
  • Close: X button (top-right) + Esc key
  • Focus trap (keyboard focus stays inside)
  • Animation: fade + slight scale (0.98 → 1), 150ms

HTML

<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title">
  <div class="modal__backdrop"></div>
  <div class="modal__panel">
    <header class="modal__head">
      <h2 id="modal-title" class="modal__title">Create new issue</h2>
      <button class="modal__close" aria-label="Close">
        <svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
          <line x1="3" y1="3" x2="13" y2="13" stroke="currentColor" stroke-width="1.5"/>
          <line x1="13" y1="3" x2="3" y2="13" stroke="currentColor" stroke-width="1.5"/>
        </svg>
      </button>
    </header>

    <div class="modal__body">
      <!-- Form content -->
    </div>

    <footer class="modal__foot">
      <button class="btn btn--ghost">Cancel</button>
      <button class="btn btn--primary">Create issue</button>
    </footer>
  </div>
</div>

CSS

.modal {
  position: fixed;
  inset: 0;
  z-index: 100;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--sp-5);
}

.modal__backdrop {
  position: absolute;
  inset: 0;
  background: rgba(0, 0, 0, 0.5);
  animation: modal-backdrop-in 150ms ease-out;
}

@keyframes modal-backdrop-in {
  from { opacity: 0; }
  to   { opacity: 1; }
}

.modal__panel {
  position: relative;
  width: 100%;
  max-width: 520px;
  background: var(--surface-1);
  border: 1px solid var(--hairline-strong);
  border-radius: var(--r-lg);
  box-shadow: 0 24px 48px rgba(0, 0, 0, 0.5);
  display: flex;
  flex-direction: column;
  max-height: 90vh;
  animation: modal-panel-in 180ms ease-out;
}

@keyframes modal-panel-in {
  from { opacity: 0; transform: scale(0.96); }
  to   { opacity: 1; transform: scale(1); }
}

.modal__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: var(--sp-5) var(--sp-6);
  border-bottom: 1px solid var(--hairline);
}

.modal__title {
  font-size: var(--text-lg);
  font-weight: 600;
  color: var(--ink);
  margin: 0;
  letter-spacing: -0.01em;
}

.modal__close {
  width: 28px;
  height: 28px;
  border-radius: var(--r-sm);
  color: var(--ink-muted);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  transition: background 120ms ease, color 120ms ease;
}

.modal__close:hover { background: var(--surface-2); color: var(--ink); }
.modal__close:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }

.modal__body {
  padding: var(--sp-6);
  overflow-y: auto;
  flex: 1;
}

.modal__foot {
  display: flex;
  justify-content: flex-end;
  gap: var(--sp-3);
  padding: var(--sp-4) var(--sp-6);
  border-top: 1px solid var(--hairline);
}

Anti-patterns

  • Backdrop > 0.6 opacity (oppressive)
  • Animation > 250ms (feels slow)
  • No Esc key handler
  • No focus trap (Tab escapes modal)
  • Two competing primary buttons in footer
  • Modal that can't scroll on small screens

9. Form inputs (Linear-style)

Used in: All forms, settings, search.

Anatomy

  • 3640px height
  • 1px hairline-strong border, surface-sunken background
  • Border-radius: 6px
  • Focus: border becomes accent, 2px focus ring with offset
  • Label above input, mono caps, small
  • Help text below, smaller, muted

HTML

<div class="field">
  <label for="title" class="field__label">Title</label>
  <input
    id="title"
    type="text"
    class="field__input"
    placeholder="What needs to be done?"
    value=""
  >
  <p class="field__help">Use a verb-led sentence. "Fix checkout error" not "Checkout error".</p>
</div>

<div class="field">
  <label for="status" class="field__label">Status</label>
  <select id="status" class="field__select">
    <option>Backlog</option>
    <option selected>In progress</option>
    <option>In review</option>
    <option>Done</option>
  </select>
</div>

CSS

.field {
  display: flex;
  flex-direction: column;
  gap: var(--sp-2);
  margin-bottom: var(--sp-5);
}

.field__label {
  font-family: var(--font-mono);
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: 0.1em;
  color: var(--ink-muted);
  font-weight: 500;
}

.field__input,
.field__select,
.field__textarea {
  width: 100%;
  height: 36px;
  padding: 0 var(--sp-3);
  background: var(--surface-sunken);
  border: 1px solid var(--hairline-strong);
  border-radius: var(--r-md);
  font-size: var(--text-sm);
  color: var(--ink);
  transition: border-color 120ms ease, background 120ms ease;
}

.field__input::placeholder { color: var(--ink-subtle); }

.field__input:hover,
.field__select:hover { border-color: var(--ink-muted); }

.field__input:focus,
.field__select:focus {
  border-color: var(--accent);
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}

.field__input:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.field__textarea {
  height: auto;
  min-height: 80px;
  padding: var(--sp-3);
  resize: vertical;
  font-family: inherit;
  line-height: 1.5;
}

.field__help {
  font-size: 12px;
  color: var(--ink-subtle);
  margin: 0;
  line-height: 1.4;
}

.field--error .field__input,
.field--error .field__select {
  border-color: var(--bad);
}

.field--error .field__help {
  color: var(--bad);
}

Anti-patterns

  • Placeholder as label (loses on focus)
  • Label inside input (accessibility disaster)
  • No focus state (browser default is fine but design your own)
  • Border that disappears on focus with no replacement
  • Native select without styling (looks like 2005)

10. Notifications (toasts)

Used in: Action confirmations, alerts, system messages.

Anatomy

  • Fixed position (top-right or bottom-right)
  • Slides in from edge, fades out on auto-dismiss
  • Icon + title + optional description + close button
  • Severity variants: info, success, warning, error
  • Auto-dismiss after 46s (with progress bar)
  • Stack vertically if multiple

HTML

<div class="toast toast--success" role="status" aria-live="polite">
  <span class="toast__icon" aria-hidden="true"></span>
  <div class="toast__body">
    <p class="toast__title">Issue created</p>
    <p class="toast__desc">ENG-1421 has been added to your backlog.</p>
  </div>
  <button class="toast__close" aria-label="Dismiss">
    <svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true">
      <line x1="3" y1="3" x2="11" y2="11" stroke="currentColor" stroke-width="1.5"/>
      <line x1="11" y1="3" x2="3" y2="11" stroke="currentColor" stroke-width="1.5"/>
    </svg>
  </button>
  <div class="toast__progress" aria-hidden="true"></div>
</div>

CSS

.toast {
  position: fixed;
  bottom: var(--sp-5);
  right: var(--sp-5);
  z-index: 90;
  display: flex;
  align-items: flex-start;
  gap: var(--sp-3);
  width: 360px;
  padding: var(--sp-4);
  background: var(--surface-1);
  border: 1px solid var(--hairline-strong);
  border-radius: var(--r-lg);
  box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4);
  overflow: hidden;
  animation: toast-in 200ms ease-out;
}

@keyframes toast-in {
  from { opacity: 0; transform: translateX(20px); }
  to   { opacity: 1; transform: translateX(0); }
}

.toast--success { border-left: 3px solid var(--good); }
.toast--warning { border-left: 3px solid var(--warn); }
.toast--error   { border-left: 3px solid var(--bad); }
.toast--info    { border-left: 3px solid var(--accent); }

.toast__icon {
  width: 20px;
  height: 20px;
  border-radius: 50%;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font-size: 11px;
  font-weight: 600;
  flex-shrink: 0;
}

.toast--success .toast__icon { background: var(--good); color: var(--surface); }
.toast--warning .toast__icon { background: var(--warn); color: var(--surface); }
.toast--error   .toast__icon { background: var(--bad);  color: var(--surface); }
.toast--info    .toast__icon { background: var(--accent); color: var(--surface); }

.toast__body { flex: 1; min-width: 0; }
.toast__title { font-size: var(--text-sm); font-weight: 600; color: var(--ink); margin: 0 0 2px 0; }
.toast__desc  { font-size: 12px; color: var(--ink-muted); margin: 0; line-height: 1.4; }

.toast__close {
  color: var(--ink-muted);
  width: 20px;
  height: 20px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border-radius: 3px;
  transition: background 120ms ease, color 120ms ease;
}

.toast__close:hover { background: var(--surface-2); color: var(--ink); }

.toast__progress {
  position: absolute;
  bottom: 0;
  left: 0;
  height: 2px;
  background: var(--accent);
  animation: toast-progress 5s linear forwards;
}

@keyframes toast-progress {
  from { width: 100%; }
  to   { width: 0; }
}

Anti-patterns

  • Toasts that don't auto-dismiss (stale UI)
  • Toasts at top-center (covers content)
  • Bouncy entrance animation (distracting)
  • No severity distinction (all toasts look the same)
  • No close button (user feels trapped)

Component states summary

Every component above must have:

State Treatment
Default As designed
Hover Subtle background tint or border strengthen
Focus-visible 2px accent ring, 12px offset
Active / pressed transform: scale(0.98) 80ms OR slight darken
Disabled Opacity 0.5, no hover, cursor: not-allowed
Loading Spinner OR skeleton, never both
Empty One sentence + one action (see §3)
Error Border + text in --bad, specific message

If any state is missing, the design breaks on the edges. Don't ship without all 8.


Component checklist (before shipping)

For every component on the page, verify:

  • All 8 states defined
  • Keyboard accessible (Tab, Enter, Escape where applicable)
  • Screen reader labels (aria-label on icon-only)
  • Touch targets ≥ 44×44px on mobile
  • No layout shift on hover/focus
  • Reduced motion respected
  • Tested in light AND dark theme

  • For token system → color.md
  • For typography setup → typography.md
  • For motion → motion.md
  • For anti-patterns → anti-patterns.md
  • For specific sub-styles → minimal-ui-patterns.md, editorial-patterns.md, brutalist-patterns.md
  • For final QA → checklist.md