Daniel Petrevski
Workbench Design system playground

A design system you can touch.

Every component here shares one set of tokens. Change the theme, mode, corner style, density or state, and all of them follow at once. Each one comes with a rule for when to use it and markup you can copy.

30components, live and editable
3style kits, exportable to Figma
4.5:1minimum text contrast, checked live
44pxminimum touch target

Three rules every component follows

How it was built

I built Workbench by directing Claude, then reviewed each round the way a stranger would: a hiring manager with ten seconds, an accessibility auditor, a developer deciding whether to build on it. Those reviews caught real problems, like a selection card with radio semantics but no keyboard support, and five dark-mode colors below WCAG contrast.

The speed came from the tool. Deciding what to fix, in what order, and when a suggested fix was wrong came from six years of shipping product design. The audit above lets you check the result yourself.

Buttons

Radius sm, weight 500

Use primary for the one action you want taken on a screen. Avoid more than one primary button in the same view.

Variants

Sizes

Loading

Link and group

<button class="btn btn-primary">Save changes</button>
<button class="btn btn-secondary">Cancel</button>
<button class="btn btn-outline">Learn more</button>
<button class="btn btn-ghost">Skip</button>
<button class="btn btn-destructive">Delete project</button>
<button class="btn btn-success">Approve request</button>
<button class="btn btn-primary btn-xs">Extra small</button>
<button class="btn btn-primary btn-sm">Small</button>
<button class="btn btn-primary btn-lg">Large</button>
<button class="btn btn-primary btn-xl">Extra large</button>
<button class="btn btn-primary btn-loading" disabled aria-busy="true">
  <span class="spinner"></span>Saving…
</button>
<button class="btn-link">View full changelog</button>
<div class="btn-group" role="group" aria-label="Range">
  <button class="btn btn-secondary btn-sm" aria-pressed="false">Day</button>
  <button class="btn btn-secondary btn-sm" aria-pressed="true">Week</button>
</div>

Input

Radius sm, 1px border

Use for a single line of free text. Avoid for choices from a fixed list. Use a select instead.

Basic

Enter a work email, like name@company.com

Search and password

Prefix, suffix and stepper

USD

Textarea

<label for="email">Work email</label>
<input id="email" type="email" inputmode="email" autocomplete="email"
  placeholder="name@company.com" aria-describedby="email-hint">
<span class="hint" id="email-hint">Enter a work email, like name@company.com</span>

<div class="input-wrap">
  <span class="input-icon-l"><svg>...</svg></span>
  <input class="has-icon-l has-icon-r" type="search" placeholder="Search projects">
  <button class="input-icon-r" aria-label="Clear search"><svg>...</svg></button>
</div>

<div class="input-affix">
  <span class="affix pre">$</span>
  <input type="text" inputmode="decimal" value="49.00">
  <span class="affix suf">USD</span>
</div>

<div class="stepper">
  <button aria-label="Decrease seats">&minus;</button>
  <input type="number" value="4" min="0">
  <button aria-label="Increase seats">+</button>
</div>

<textarea class="ta" placeholder="Add context for the team"></textarea>

Select

Native element, styled

Use when there are 4 to 12 known options. Avoid past about 12, where search beats scanning.

Multi-select with chips

Design Q3
<label for="plan">Plan</label>
<select id="plan">
  <option>Starter</option>
  <option selected>Pro</option>
  <option>Team</option>
</select>

<div class="chip-input">
  <span class="tag-chip">Design<button aria-label="Remove Design"><svg>...</svg></button></span>
  <input type="text" placeholder="Add a tag, press Enter">
</div>

Toggle

On uses the brand fill

Use for a setting that takes effect immediately. Avoid when the change needs a separate save step. Use a checkbox instead.

<label class="toggle-row">
  <span class="switch">
    <input type="checkbox" role="switch" checked>
    <span class="track"></span><span class="knob"></span>
  </span>
  Email notifications
</label>

Segmented control

Single choice, always visible

Use for 2 to 4 views where every option should stay visible. Avoid past 4 options. That is a tab bar or a select.

<div class="segmented" role="group" aria-label="View">
  <button class="active" aria-pressed="true">List</button>
  <button aria-pressed="false">Board</button>
  <button aria-pressed="false">Timeline</button>
</div>

Selection card

Radio semantics, card affordance

Use when each option needs room to explain itself, like a plan picker. Avoid for more than 5 options, or options that are just a short label. Use radio rows instead.

<div class="select-cards" role="radiogroup" aria-label="Plan">
  <div class="select-card active" role="radio" aria-checked="true" tabindex="0">
    <span class="sc-check"><svg>...</svg></span>
    <p class="sc-title">Pro</p>
    <p class="sc-price">$29 per month</p>
  </div>
</div>

<!-- Arrow keys move selection, Enter and Space select -->

Badges

Status color is a semantic token

Use to show status at a glance in a list or table row. Avoid more than one badge on the same item.

Active Pending review Failed sync

Sizes and dot style

Active Active Active Live
<span class="badge badge-success">Active</span>
<span class="badge badge-pending">Pending review</span>
<span class="badge badge-error">Failed sync</span>
<span class="badge badge-success badge-sm">Active</span>
<span class="badge badge-success badge-lg">Active</span>
<span class="badge badge-success badge-dot">Live</span>

Alert

Info and warning

Use for information that affects the whole page or a whole action. Avoid for a single-field error. Use the field hint instead.

Weekly digest paused

You won't get the Monday summary until you turn it back on.

<div class="alert warn" role="alert">
  <svg aria-hidden="true">...</svg>
  <div>
    <h3>Billing needs attention</h3>
    <p>Your card was declined. Update your payment method to keep your plan active.</p>
  </div>
</div>

Tabs

2 to 5 items

Use to switch between views of the same object. Avoid for sequential steps. Use a stepper instead.

<div class="tabs" role="tablist" aria-label="Project">
  <button class="tab-btn active" role="tab" aria-selected="true">Overview</button>
  <button class="tab-btn" role="tab" aria-selected="false" tabindex="-1">Activity</button>
</div>

<!-- Left and right arrows move between tabs -->

Card

Shadow token, hover lift

Use to group a preview with its metadata in a grid. Avoid nesting a card inside another card.

Grid card

Q3 brand refresh

Updated Aug 4, 3 collaborators

Horizontal card

Onboarding flow v2

In review, assigned to Ana

Media card

Case study

Redesigning checkout

6 min read

Pricing card

<div class="sample-card">
  <div class="thumb"></div>
  <h3>Q3 brand refresh</h3>
  <p>Updated Aug 4, 3 collaborators</p>
</div>

<div class="card-horizontal">
  <div class="thumb"></div>
  <div class="ch-body"><h3>Onboarding flow v2</h3><p>In review, assigned to Ana</p></div>
</div>

<div class="card-media">
  <div class="cm-cover"><span class="cm-badge">Case study</span></div>
  <div class="cm-body"><h3>Redesigning checkout</h3><p>6 min read</p></div>
</div>

<div class="card-pricing featured">
  <p class="cp-tier">Pro</p>
  <p class="cp-price">$29<span> per month</span></p>
  <ul><li><svg>...</svg>Unlimited projects</li></ul>
  <button class="btn btn-primary">Choose Pro</button>
</div>

Stat card

Trend is a semantic token

Use for one key number on a dashboard. Avoid more than 4 to 6 on a screen, or none of them read as key.

Monthly recurring revenue

$8,420 +12%

Churned seats

14 −3%

<div class="stat-card">
  <p class="stat-label">Monthly recurring revenue</p>
  <p class="stat-value">$8,420 <span class="stat-trend up">+12%</span></p>
</div>

List row

Avatar, details, status

Use for scannable rows in a settings or team list. Avoid when rows need more than 2 or 3 data points. Use a table instead.

Jordan Diaz

jordan@company.com

Active

Ana Mora

Invited 3 days ago

Pending

Selectable and reorderable

Homepage hero copy

Step 1 of 4

Pricing page update

Step 2 of 4

<div class="list-row">
  <div class="list-avatar" aria-hidden="true">JD</div>
  <div class="list-meta">
    <p class="list-name">Jordan Diaz</p>
    <p class="list-sub">jordan@company.com</p>
  </div>
  <span class="badge badge-success">Active</span>
</div>

<div class="list-row">
  <span class="drag-handle" aria-hidden="true"><svg>...</svg></span>
  <input type="checkbox" checked aria-label="Homepage hero copy">
  <div class="list-meta"><p class="list-name">Homepage hero copy</p></div>
</div>

Table

Row hover uses the sunken surface

Use when rows need to be compared column by column. Avoid on narrow screens without horizontal scroll. Tables don't reflow.

NameStatusOwnerUpdated
Onboarding flow v2 Active Ana Mora Aug 4
Pricing experiment Pending Jordan Diaz Aug 2
Q2 retro notes Failed sync Ravi Kapoor Jul 29
<div class="table-wrap">
  <table class="ds-table">
    <thead>
      <tr>
        <th scope="col"><input type="checkbox" aria-label="Select all rows"></th>
        <th scope="col">Name</th><th scope="col">Status</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td><input type="checkbox" checked aria-label="Select Onboarding flow v2"></td>
        <td class="t-name">Onboarding flow v2</td>
        <td><span class="badge badge-success">Active</span></td>
      </tr>
    </tbody>
  </table>
</div>

Accordion

Grid-row animation, respects reduced motion

Use for FAQs or optional detail someone can choose to open. Avoid for content people need on every visit. It only adds a click.

Everything stays intact for 30 days in case you change your mind. After that it is permanently deleted.

Yes. Guests can join individual projects without a full seat.

50% off with a valid .edu email, applied automatically at checkout.

<div class="accordion-item">
  <h3>
    <button class="accordion-trigger" aria-expanded="true" aria-controls="panel-1" id="trigger-1">
      Question text
      <span class="chev" aria-hidden="true"><svg>...</svg></span>
    </button>
  </h3>
  <div class="accordion-panel open" id="panel-1" role="region" aria-labelledby="trigger-1">
    <div class="accordion-panel-inner"><p>Answer text</p></div>
  </div>
</div>

<!-- Closed panels get the inert attribute so their content can't be tabbed into -->

Toast

Live region, closes after 5 seconds

Use to confirm that an action succeeded or failed without blocking the page. Avoid for anything the person must act on. That belongs in a modal or an inline banner.

<div class="toast-region" aria-live="polite">
  <div class="toast success">
    <svg aria-hidden="true">...</svg>
    <div class="toast-body">
      <p class="t-title">Changes saved</p>
      <p class="t-desc">Everyone on the project can see them now.</p>
    </div>
    <button class="toast-close" aria-label="Dismiss notification"><svg>...</svg></button>
  </div>
</div>

Bottom tab bar

Safe-area aware, 44px tap targets

Use as the main navigation of a mobile app, with 3 to 5 destinations. Avoid on tablet and desktop widths, where a sidebar like the one on this page does the job.

Home

This frame is fixed at 320px, the narrowest phone width still in common use, so the tab bar sits against real content and the safe-area edge.

<nav class="tab-bar" aria-label="App">
  <button class="tab-bar-item active" aria-current="page"><svg>...</svg>Home</button>
  <button class="tab-bar-item"><svg>...</svg>Search</button>
</nav>

/* padding-bottom: max(8px, env(safe-area-inset-bottom))
   keeps labels clear of the home indicator on notched phones */

Bottom sheet

The mobile answer to a dropdown

Use for a short list of actions on a touch screen, where the thumb reaches the bottom more easily than a small menu. Avoid on tablet and desktop widths. Use the dropdown menu instead.

Project settings

The sheet rises from the bottom of this frame, the way it would on a phone.

<div class="sheet-overlay"></div>
<div class="bottom-sheet" role="dialog" aria-modal="true" aria-labelledby="sheet-title">
  <div class="sheet-handle" aria-hidden="true"></div>
  <h3 id="sheet-title">Project actions</h3>
  <button class="sheet-action">Duplicate project</button>
  <button class="sheet-action danger">Delete project</button>
</div>

<!-- Escape and a tap on the overlay close it, focus returns to the trigger -->

Site navigation

Collapses to a menu, not a tab bar

Use for a marketing or content site where every page needs the same top-level wayfinding. Avoid the bottom tab bar here. A site nav holds 5 to 8 links and a call to action. A tab bar can't.

Desktop

Mobile

Page content

The menu pushes down from the bar instead of sliding in as a drawer. There is no gesture to discover and no hidden way to close it: tap the icon, scan the list, tap a link.

<nav class="site-nav" aria-label="Main">
  <a class="site-nav-logo" href="/">Harbor</a>
  <div class="site-nav-links">
    <a href="/product" class="active" aria-current="page">Product</a>
    <a href="/pricing">Pricing</a>
  </div>
  <button class="btn btn-primary btn-sm">Start free trial</button>
  <button class="nav-hamburger" aria-expanded="false" aria-controls="mobile-menu" aria-label="Open menu"><svg>...</svg></button>
</nav>
<div class="mobile-menu" id="mobile-menu" hidden>
  <a href="/product" class="active" aria-current="page">Product</a>
  <button class="btn btn-primary">Start free trial</button>
</div>

Avatar

Sizes 18, 28, 36, 48, 64

Use initials by default and a photo when one exists. Avoid the status dot unless presence matters in that view.

<span class="avatar avatar-xs">JD</span>
<span class="avatar avatar-sm">JD</span>
<span class="avatar">AM</span>
<span class="avatar avatar-lg">RK</span>
<span class="avatar avatar-xl">NM</span>
<span class="avatar-wrap">
  <span class="avatar">SC</span>
  <span class="avatar-status online" role="img" aria-label="Online"></span>
</span>

Progress bar

Track uses the line token, fill uses brand

Use when progress is measurable and the total is known. Avoid for waits of unknown length. Use a spinner instead.

Uploading assets68%
Storage used92%
<div class="progress-label"><span id="pl-upload">Uploading assets</span><span>68%</span></div>
<div class="progress" role="progressbar" aria-labelledby="pl-upload"
  aria-valuenow="68" aria-valuemin="0" aria-valuemax="100">
  <div class="progress-fill" style="width:68%"></div>
</div>

Checkbox and radio

Checkbox for many, radio for one

Use checkboxes when more than one option can be true, radios when exactly one can. Avoid a single checkbox for an on or off setting. Use a toggle instead.

<label class="check-row">
  <input type="checkbox" checked>
  <span class="check-box" aria-hidden="true"></span>
  Email me weekly summaries
</label>
<label class="check-row">
  <input type="radio" name="billing">
  <span class="check-box radio" aria-hidden="true"></span>
  Monthly billing
</label>

Tooltip

Shows on hover and keyboard focus

Use to label an icon-only control. Avoid for instructions someone needs before acting. Those belong inline.

Copies the shareable link
<span class="tip-wrap">
  <button class="btn btn-secondary btn-icon" aria-label="Copy link" aria-describedby="tip-copy">
    <svg aria-hidden="true">...</svg>
  </button>
  <span class="tip" role="tooltip" id="tip-copy">Copies the shareable link</span>
</span>

Modal

Focus trapped, Escape closes

Use to confirm a destructive or hard-to-reverse action. Avoid for anything the person can undo with one click.

<div class="modal-overlay">
  <div class="modal-box" role="dialog" aria-modal="true" aria-labelledby="modal-title" tabindex="-1">
    <h2 id="modal-title">Delete this project?</h2>
    <p>It will be removed for every collaborator. This can't be undone.</p>
    <div class="modal-actions">
      <button class="btn btn-secondary">Cancel</button>
      <button class="btn btn-destructive">Delete project</button>
    </div>
  </div>
</div>

Empty state

Icon, headline, one action

Use to say what's missing and what to do next. Avoid a bare "No results" with no way forward. That's a dead end, not a state.

No projects yet

Projects you create show up here, along with anyone you invite to them.

<div class="empty-state">
  <svg aria-hidden="true">...</svg>
  <h3>No projects yet</h3>
  <p>Projects you create show up here, along with anyone you invite to them.</p>
  <button class="btn btn-primary btn-sm">Create a project</button>
</div>

Breadcrumb

Chevron separators, current page in ink

Use to show where a page sits in a nested hierarchy. Avoid on a flat, one-level site. It adds a control with nothing to navigate.

<nav class="breadcrumb" aria-label="Breadcrumb">
  <a href="/">Home</a>
  <span class="sep" aria-hidden="true"><svg>...</svg></span>
  <a href="/projects">Projects</a>
  <span class="sep" aria-hidden="true"><svg>...</svg></span>
  <span class="current" aria-current="page">Q3 brand refresh</span>
</nav>

Pagination

36px controls, 44px tap area

Use to move through a known, countable set of pages. Avoid for an endless feed. Use a "Load more" button instead.

<nav class="pagination" aria-label="Pagination">
  <button class="page-btn nav" aria-label="Previous page"><svg>...</svg></button>
  <button class="page-btn active" aria-current="page">1</button>
  <button class="page-btn">2</button>
  <span class="page-ellipsis" aria-hidden="true">…</span>
  <button class="page-btn nav" aria-label="Next page"><svg>...</svg></button>
</nav>

Dropdown menu

Arrow keys move, Escape closes

Use for a short list of actions on a single item. Avoid past about 8 items. That needs its own page or a select.

<div class="dropdown">
  <button class="btn btn-secondary dropdown-trigger" aria-haspopup="menu" aria-expanded="false"
    aria-controls="menu">Actions <svg aria-hidden="true">...</svg></button>
  <div class="dropdown-panel" id="menu" role="menu" hidden>
    <button class="dropdown-item" role="menuitem">Duplicate<kbd>⌘D</kbd></button>
    <div class="dropdown-sep" role="separator"></div>
    <button class="dropdown-item danger" role="menuitem">Delete project</button>
  </div>
</div>

Skeleton loader

Shimmer respects reduced motion

Use to hold the exact shape of a row or card while its content loads. Avoid for waits under 300ms. A flash of skeleton looks more broken than a blank beat.

<div class="skeleton-block" role="status" aria-label="Loading content">
  <div class="skel-row">
    <span class="skel circle"></span>
    <div class="skel-lines"><span class="skel l1"></span><span class="skel l2"></span></div>
  </div>
  <span class="skel media"></span>
</div>

Slider

18px thumb, 8px track

Use for a value picked from a range, where the feel matters. Avoid when the exact number matters more. Use a number input instead.

68%
<div class="slider-field">
  <span class="slider-label">
    <label for="volume">Playback volume</label>
    <b><output for="volume">68%</output></b>
  </span>
  <input class="slider-track" id="volume" type="range" min="0" max="100" value="68">
</div>
Tokens

Primitives Raw values with no meaning attached

Violet ramp

violet-100
violet-400
violet-600
violet-700

Neutral ramp

ink-05
ink-20
ink-60
ink-100

Semantic What components reference

Color, paired the way it's used

action-primary
action-warn
status-positive
status-negative
destructive

Spacing

4, 8, 12, 16, 20, 24, 32, 40, 48, 64 px

Radius

xs 4 for swatches, sm 6 for controls, default 10 for cards, pill for chips and badges

Touch target

44px on every icon-only control. The icon can stay small; the tappable area can't.

Type

Caption 11, small 12, body 14, headings 18 and 20
Instrument Sans for everything you read, JetBrains Mono only for code

Sizing

2xs 18, xs 24, sm 28, md 36, lg 48, xl 64. Every avatar, checkbox and swatch uses one of these.

Icons

xs 14 in dense rows, sm 16 next to body text, md 18 in alerts

Breakpoint

One cutover at 860px, where the sidebar becomes a scrolling row. Phones and portrait tablets get one column; landscape tablets and up keep two.

Only the semantic layer changes between light and dark. Every color that sits behind white text has its own -fill token, pinned to a value that clears 4.5:1 in both modes, so the plain color can lighten in dark mode for readable text without breaking the buttons that use it. The contrast badges above measure the real pairing and update when you change theme, mode or style.

Figma export

3 style kits, CSS and vectors

Use to hand off a whole style direction at once. Avoid mixing kits on one screen.

Paste one of these blocks into your project to switch the whole system. Color stays the same across all three on purpose. Only radius and shadow change.

Every value is a literal color and pixel, so it survives the trip into Figma. Copy an SVG and paste it onto a Figma canvas with Cmd or Ctrl+V. It arrives as editable vector layers, not a flat image. Download saves the same file.