--amber-300 → --amber-600 · Curator accent only. Bitwise does not use amber — its accent is near-white (warm-100). Never reference amber-* directly in product CSS; use --accent, which resolves to amber-400 only when data-brand="curator" is set.
--slate-950 → --slate-50 · Etch product + glass surfaces across all brands
Copper and signal palette — primitive · --copper-300 → --copper-600 (Etch accent) · signal-green/amber/red/blue
copper 300→600
signal: green · amber · red · blue
Signal colors — semantic status · --color-success / --color-warning / --color-error / --color-info
success
warning
error
info
Brand palette comparison — three identities, one token vocabulary · switch brands above to compare
Bitwise
Helvetica Neue · warm-950 bg · warm-100 accent
Monochromatic industrial. Contrast IS the brand signal — zero hue in accent. Near-black warm surfaces, warm-white as the only emphasis. Japandi restraint · Tom Ford dark luxury.
Warm editorial amber. Cormorant Garamond draws from literary publishing tradition. Amber is deliberate warmth: curation as a human act, not a machine function.
Dark copper precision. Playfair Display is high-contrast editorial — fine strokes against bold ones, like a ruling pen on drafting paper. Slate is cool mineral authority. Copper signals craft and material quality: this tool makes things that endure.
bg
surface
s-2
accent
muted
These three brands share the same semantic token names but resolve to completely different visual identities. Each card is pinned to its own brand via data-brand — it does not change when you switch the global brand selector. This is the core mechanism: any element with data-brand inherits that brand's full token set. The underlying primitive palettes are entirely separate: warm-* for BW/CUR, slate-* for ETCH.
Accent color — --accent / --accent-hover / --accent-subtle / --accent-border
--accent
--accent-hover
--accent-subtle
--accent-border
--accent-border-mid
--accent-glow
--accent is the primary signal color — high contrast against --bg. BW: warm-100 (near-white, zero hue — contrast is the signal) · CUR: amber-400 (golden editorial warmth) · ETCH: copper-400 (metallic craft). Switch brands above to see each identity's accent resolve.
Accent alpha scale — tint 3% → focus 18% · adapts to any background surface
Alpha colors adapt to any background — --accent-subtle at 5% reads correctly on both --bg and --surface-2 without a separate token per surface. Same alpha scale, same brand signal, any context.
--bg is the canvas. BW: warm-950 (near-black, warm undertone prevents clinical coldness) · CUR: warm-900 (deep dark brown, one step lighter) · ETCH: slate-950 (cool mineral black, entirely different ramp). Warm vs. cool is the first brand differentiation the eye registers before accent color lands.
Semantic token contract — three brands, one vocabulary · same names resolve to different values per brand
These brands share zero raw values. They share only token names. --accent is warm-white on Bitwise, amber on Curator, copper on Etch. The token vocabulary is the contract — raw values are implementation details that change per brand without touching a single component.
Text colors — --text / --text-strong / --text-muted · all brand-switched
--text-strong — headings and emphasis
--text — primary body copy
--text-muted — supporting and secondary text
Text tokens draw from the same palette ramp as backgrounds: warm-* in BW and CUR, slate-* in ETCH. Even at rest, body text carries brand temperature through its underlying primitive ramp.
Signal color tokens — --color-success / --color-warning / --color-error / --color-info and their border variants
Display sizes (6xl–3xl) use --font-display, which changes with brand. Body sizes use --font-body (IBM Plex Sans, shared).
Mobile scalingNo explicit mobile type scale is defined — the system relies on rem units and browser zoom. On pointer: coarse devices, all interactive targets enforce a minimum 44px hit area via min-height: 44px; min-width: 44px (WCAG 2.5.5 AAA). Text sizes themselves do not scale up on mobile.
Touch targetsButtons, inputs, checkboxes, radio controls, and nav items all meet the 44px minimum on coarse-pointer devices. Decorative elements and text-only links are exempt. Use @media (pointer: coarse) to verify in DevTools.
Display typeface — --font-display · brand voice · switches per brand (Helvetica Neue / Cormorant / Playfair)
Aa
BITWISE
Helvetica Neue
System grotesque
Subway · Precision · Authority
Aa
CURATOR
Cormorant Garamond
Editorial serif
Warmth · Culture · Voice
Aa
ETCH
Playfair Display
Epigraphic serif
Craft · Permanence · Ritual
Three typefaces. Zero shared values. Helvetica Neue enforces Bitwise's subway-poster authority. Cormorant Garamond gives Curator its editorial warmth. Playfair Display marks Etch as a tool of craft precision — fine-stroke luxury over copper. All three resolve through the single token --font-display — set per brand, consumed by every component.
Duration governs perceived responsiveness. Fast is the floor for any interaction that should feel immediate. Base is the default for all layout changes. Slow is reserved for full-screen events — overusing it makes the UI feel sluggish. The bars run continuously to let you compare speeds at a glance.
Easing curves — --ease-default / --ease-in / --ease-out · hover each box to trigger
--ease-default
cubic-bezier(0.16, 1, 0.3, 1)
Decelerate sharply, settle fast. Default for all reveals and entrances.
--ease-in
cubic-bezier(0.4, 0, 1, 1)
Accelerates to full speed. Use for exits and dismissals.
--ease-out
cubic-bezier(0, 0, 0.2, 1)
Starts fast, decelerates to rest. Use for entrances from off-screen.
--ease-springcubic-bezier(0.34, 1.56, 0.64, 1) — overshoots past the target and bounces back. Also in the system. Use sparingly: only for playful confirmation moments (Curator). Avoid in Bitwise and Etch where restraint is the brand signal.
pairing rulePair --ease-in (exit) with --ease-out (entrance) on the same axis. Never use --ease-default for exits — deceleration reads as hesitation when an element should be leaving.
Reduced motion contract — prefers-reduced-motion: reduce · all animations and transitions suppressed
Every animation in this system includes a @media (prefers-reduced-motion: reduce) block. Duration collapses to 0ms. Transform-based motion is suppressed. Opacity cross-fades may remain at reduced intensity if they serve a functional role (e.g. content swap legibility).
The duration bar specimens above are suppressed in reduced-motion contexts via the .sc-motion-demo rule — this is the behavior component implementers should replicate.
Vestibular disorders, epilepsy, and cognitive load conditions all make unnecessary motion harmful. The preference is an explicit user signal. Add the media query guard before marking any animated component complete. This is not optional.
Never use raw integers in component CSS. Without this scale, a tooltip at z:9999 will punch through a modal at z:100 — stacking becomes a negotiation between unknown values. The scale creates contracts: --z-nav (600) always wins; --z-modal (400) always wins over --z-overlay (300); overlays always win over dropdowns (200). Name the layer, not the number.
Gradient tokens — --gradient-brand / --gradient-surface · composited over bg
--gradient-hero
--gradient-ambient
--gradient-shimmer
Hero: radial ambient glow from top. Ambient: directional tint. Shimmer: skeleton loading sweep. All compose from --accent-subtle — switch brands to see each identity's gradient.
Glass elevation specimen — three levels as live components · bg + blur + border + shadow per level
Level 1
2mm · bg 2% · border 8%
Level 2
6mm · bg 4% · blur 8px
Level 3
12mm · bg 6% · blur 20px
Glass tokens model physical thickness as elevation. Thickness = hierarchy. Cast shadows are tight and directional — not glows.
Elevation modelOn a dark canvas, elevation = reduced transparency. Glass 1 is closest to the surface (most opaque relative to layer). Glass 3 floats (most separation, highest blur). No atmospheric haze — only optical thickness.
vs. IBM CarbonCarbon dark mode uses progressively lighter surface fills as elevation increases (bg → #161616, surface → #262626, raised → #393939). Our glass tokens achieve the same hierarchy through transparency increase + blur increase, not fill lightening — preserving the dark base color at all levels.
When to useGlass 1: nav items, bare dividers. Glass 2: cards, sidebars, panels. Glass 3: dialogs, drawers, puck, the highest focal layer. Never skip a level — the hierarchy must be readable in order.
Engraved / debossed — elements that are cut into the surface rather than elevated above it.
Prototype pattern — demonstrates documentation shell: on-page nav with border-left active indicator and padding-left transition via --duration-fast + --ease-out. Prose scrolls independently. Production: active state driven by IntersectionObserver on section headings.
Surface & Light
Pointer-reactive light system. The cursor is the light source — glass surfaces catch specular highlights that track the mouse. Three brand textures activate automatically per brand.
Surface Textures
Brand Textures
Three SVG feTurbulence textures — each auto-activates per brand via body::before. Classes available for explicit use.
Textures are applied as body::before pseudo-elements — fixed, full-viewport, pointer-events none, z-index -1. Downstream apps get the texture automatically by setting data-brand on <html>. No additional markup needed.
Glass Stacking
Glass on Surface
Move the cursor over this panel — the specular highlight tracks the pointer across all glass surfaces simultaneously. Each level shows different highlight intensity (L1=4%, L2=8%, L3=12%).
Level 1
2mm glass
Nav items, bare containers
Level 2
6mm glass
.tbw-card — hover to lift
Level 3
12mm glass
Dialogs, puck, highest elevation
Specular tokens: --glass-1-specular (4%), --glass-2-specular (8%), --glass-3-specular (12%). All three reference --pointer-x/--pointer-y, written by components/light/light.js. Graceful degradation: without light.js, the gradient defaults to 50%/50% (centred). The texture behind the glass comes from body::before — glass blur lets the grain show through.
Micro-animations
Motion Tokens
--ease-spring · cubic-bezier(0.34, 1.56, 0.64, 1) — used on hover lift, button press, checkbox/radio spring-in. .tbw-enter — scroll-driven entrance via animation-timeline: view().
Card hover lift
Buttons: translateY(-1px) on hover, scale(0.97) on active. Cards: translateY(-3px) on hover. All wrapped in prefers-reduced-motion: no-preference.
Atoms
Indivisible primitives — typography, button, input, control, badge, avatar, tag, and feedback states. No atom composes another atom.
--text-lg · --text-muted · max-width 52ch — deliberately muted to cede visual weight to the display heading. Hierarchy is the point.
This is the canonical heading/body pairing for the Bitwise brand system. The display font is the brand signal. The body copy recedes. Restraint in typographic hierarchy is not timidity — it is precision.
Body text scale — .tbw-text / .tbw-text-lg / .tbw-text-sm / .tbw-text-xs / .tbw-text-muted / .tbw-text-strong
Large body — introductory paragraphs, lead text.
Base body — primary reading text at 16px with 1.65 line-height.
Small body — supporting text, captions, UI labels at rest.
A design system is only as good as the constraints it enforces. When every spacing value, color, and type size comes from a shared token layer, the system can evolve without breaking every surface that depends on it.
Constraint is the creative act.
Etch
Tokens as the source of truth
Tokens are not just variables — they are decisions made once and deferred everywhere. The value of --accent can change per brand without touching a single component.
.tbw-prose — applies to any rich text container (article, section, .content). Handles heading hierarchy, blockquote, pre/code, lists, links, and flow spacing. max-width: 68ch. Never use inside data-dense UIs — prose is for reading contexts only.
Button
Button variants — btn-primary / btn-ghost · hover and focus are live CSS states · interact below
Primary · Ghost
Icon + label
Icon-only (square)
Loading state (static demo)
Disabled state (static demo)
class
btn-primary · btn-ghost
hover
CSS .btn-primary:hover — no JS. Hover the buttons above to see the live state. Background shifts to --accent-hover.
focus
CSS :focus-visible — keyboard only, never on mouse click. Tab to a button above to see the live focus ring: outline: 2px solid var(--accent) at 2px offset.
disabled
[data-disabled] + aria-disabled="true" — opacity 0.45. Keeps focus for screen reader announcement. Use instead of native disabled when focus retention matters.
loading
aria-busy="true" on the button. Spinner via .sc-btn-spinner (showcase chrome class — use your own in production). pointer-events:none prevents double-submit.
Hover and focus states are real CSS :hover and :focus-visible — interact with the buttons above to see them. Use Tab to keyboard-navigate. Loading and disabled states are shown as static demos since they require a specific async trigger to enter.
Inputs
Text inputs — .tbw-input / .tbw-textarea / .tbw-select · tab through to see focus state · the third input has data-error applied
Controls
Controls — .tbw-checkbox / .tbw-radio / .tbw-toggle · real interactive form controls
checkbox
tbw-checkbox-wrap (label) + tbw-checkbox (input) — always paired. The <label> wraps the input; no for/id needed when nested.
radio
tbw-radio-wrap (label) + tbw-radio (input) — same pattern as checkbox. Group radios with a shared name attribute. Wrap the group in <fieldset> + <legend>.
toggle
tbw-toggle-wrap (label) + tbw-toggle (checkbox input). Semantically a checkbox — AT announces checked/unchecked, not on/off. Use only for binary settings that take immediate effect without a submit action.
when to use toggle
Settings that apply immediately on change (e.g. "Enable notifications"). Not for form choices that require submission — use checkbox instead.
keyboard
All three: Space toggles checked state. Tab moves between controls. Native browser behavior — no JS required.
Badge · Tag
Badge and tag — .tbw-badge [data-variant] (status) / .tbw-tag (taxonomy)
DefaultSuccessWarningErrorInfoNeutral
Online
Degraded
Offline
Syncing
Idle
design-systemcss-onlytokensbitwise
element
tbw-badge — always a <span>, never interactive
variants
[data-variant="success"] · warning · error · info · neutral · default (no attribute)
tag vs badge
Badge: system-assigned status label (success, error, warning). Tag: user-defined categorical label. Never use badge for user content.
interactive tag
Use <button class="tbw-tag"> — not <span data-interactive>. Span is not keyboard-reachable.
Default: circle (border-radius: var(--radius-full)). Square: [data-shape="square"] — uses var(--radius-md). Use square for brand or system entities; circle for human users.
initials
[data-initials="JD"] — CSS content: attr(data-initials) renders text when no image is present. Max two characters. Color is drawn from --accent-subtle (bg) and --accent (text).
with image
Place <img alt="[Name]"> inside the avatar element. The alt text must be the person's name or "Profile photo for [Name]" — not empty string unless a visible name label is adjacent.
role="status" + aria-label="Loading …" — both required. role="status" makes the element a polite live region so AT announces it on insertion. aria-label provides the announcement text.
aria-hidden="true" required on every skeleton element — purely decorative. Wrap the group in <div role="status" aria-label="Loading content"> so AT announces the loading state once.
when to use
Spinner: known wait, no content preview (e.g. form submit, file upload). Skeleton: content is loading and its shape is predictable (feeds, card grids, article prose).
Progress
Progress bar (atom) — <progress class="tbw-progress"> · determinate (value/max) + indeterminate (no value)
65% complete
25% complete · sm
Loading… · indeterminate
element
Native <progress> — styled via appearance: none. Determinate: value + max attrs. Indeterminate: omit value.
indeterminate
Wrap in .tbw-progress-wrap and add a .tbw-progress-indeterminate-bar sibling. The sibling provides the sweep animation where native indeterminate styling is unreliable cross-browser.
aria-hidden="true" on every skeleton element — purely decorative. Wrap the group in <div role="status" aria-label="Loading content"> so AT announces the loading state once.
Replace with real content when loaded — never use as permanent decoration. Use when content shape is predictable (feeds, card grids, prose). Use spinner instead when no shape preview is possible.
Tag — .tbw-tag · taxonomy labels · content classification · read-only and interactive variants
DesignCSSTokensAccessibilityMotionDisabled
element
<span class="tbw-tag"> for non-interactive labels. <button class="tbw-tag"> for clickable filter chips — never a span with onclick.
tag vs badge
Tag: user-defined categorical label (article topics, card categories). Badge: system-assigned status (success, error, warning). Never use badge for user content; never use tag for system status.
interactive
Add [data-interactive] for hover/focus/active states in filter-chip contexts. [data-active] marks the selected chip.
dismissible
Add .tbw-tag-dismiss button inside the tag for removable chips. Must have aria-label="Remove [tag name]".
Purposeful combinations of atoms — a form field is label + input + hint + error. A search bar is input + icon + button. Each molecule has one clear job.
Input compositions
Form field
Form field — .tbw-field with .tbw-field-label / .tbw-field-hint / .tbw-field-error · input + label + hint + validation
We'll never share your email with anyone.
Must be at least 8 characters.
context
Any form where a single field cannot stand alone — label and hint are part of the component, not optional additions. Use tbw-field for every text, email, password, and select input in a form. Not for inline edits or search (use the search bar molecule).
when not to use
Do not wrap a standalone search or filter input in tbw-field — there is no persistent error or hint state. Do not use for read-only data display; use <dl> instead.
error copy rule
State the constraint, not the failure. Correct: "Must be at least 8 characters." — Incorrect: "Invalid password." Always explain what is required; never just that something went wrong. Avoid jargon ("failed to validate") and passive voice.
hint copy rule
Hints appear before errors — they set expectations before the user types. Keep to one sentence. Use the same tense as the label: "We'll never share your email", not "Email must not be shared."
error state
Add data-error to both .tbw-field and the tbw-input. Use <p class="tbw-field-error" role="alert"> — role="alert" announces the error immediately to screen readers without requiring the user to focus the field.
maturity
Stable. Used in production across Etch and The Bitwise sign-in flows.
Search bar — input + icon + filter pills · one molecule, one job
composition
tbw-input[type=search] + icon wrapper (position:relative parent, position:absolute icon, padding-left:var(--space-8) on input). The icon is aria-hidden="true" and pointer-events:none.
input element
Use <input type="search"> — not type="text". Browsers expose a clear button and the correct virtual keyboard on mobile. role="searchbox" is implicit.
with submit button
Wrap both in a <form role="search">. The submit button must be <button type="submit">. Join border radius: input gets border-radius: var(--radius-md) 0 0 var(--radius-md), button gets the mirror.
filter pills
Each filter is a <button aria-pressed="true|false"> — not a radio. Use aria-pressed because the set is non-exclusive (multiple filters may be active). If mutually exclusive, use <input type="radio"> inside a <fieldset>.
active state
[data-active] styles the active filter pill. Must be toggled in sync with aria-pressed. Never use color alone — add a visible border or background change.
glass-card — Glass 2 elevation. Backdrop blur. Use on surfaces placed above the noise layer or a gradient background.
compact
glass-card compact — reduced padding. Use for dense data grids, sidebar widgets, inline status cards.
action card
[data-action] — hover glow. Must add role="button" + aria-label when using a <div>. Prefer <button> or <a> as the root element to avoid manual keyboard handling.
tbw-stats container with tbw-stats-value and tbw-stats-label children. Use a <dl> when multiple stats appear in a group — <dt> for the label, <dd> for the value.
trend color
Apply --color-success or --color-error inline on tbw-stats-value. Do not rely on color alone — prefix the value with a + or − sign so the direction is communicated without color.
screen reader
The value is announced before the label. Ensure the pair makes grammatical sense in isolation: "2,847 Total components" — not "Total components 2,847". Reverse the DOM order if the visual design flips them.
Warning — design brief is incomplete. Some fields may default.
Generation failed
Could not resolve token --accent. Check your brand configuration.
Info — this component uses Glass 2 by default. Override with .glass-card.compact.
element
tbw-alert — inline feedback banner. Not a toast (transient, timed) and not a dialog (blocking). Used for persistent in-context status messages tied to a form or content region.
role
role="alert" on errors and urgent warnings — assertive live region, announced immediately. For lower-priority success and info messages, use role="status" (polite, waits for current reading to finish).
variants
[data-variant="success"] · warning · error · info · default. Each variant controls border-left color and background tint. Add tbw-alert-title only when the message needs a heading — short messages need body only.
icon
Icon is decorative — aria-hidden="true". Color alone is not sufficient to convey variant; the icon reinforces meaning for users who cannot distinguish status colors.
dismissal
Dismiss button must be <button aria-label="Dismiss">. After dismissal, move focus to the next logical element or the element that triggered the alert.
copy rule
Error alerts: name what failed and what the user can do. Correct: "Could not resolve token --accent. Check your brand configuration." Incorrect: "An error occurred." Warning alerts: state the risk and what changes if the user proceeds. Success alerts: confirm what completed; do not repeat what the user already knows they did.
Progress molecule — .tbw-progress-wrap / .tbw-progress · label + bar · determinate and indeterminate variants
Token resolution25%
Upload68%
Generating…—
element
Native <progress> — not a div. Provides accessible value to AT without extra ARIA. Always wrap in tbw-progress-wrap to contain overflow and apply rounded caps.
determinate
Set both value and max. aria-label must include the percentage: "Upload progress, 68% complete". Do not manually add aria-valuenow — the native element exposes it automatically.
indeterminate
Omit the value attribute entirely — do not set it to 0. aria-label="Loading" suffices. The shimmer animation must be suppressed under prefers-reduced-motion: reduce.
label pairing
Always show a visible percentage label alongside the bar. It conveys progress without requiring color interpretation and aids users who cannot perceive the fill length.
<nav aria-label="breadcrumb"> wrapping an <ol>. Always an ordered list — the order encodes hierarchy. Current page carries aria-current="page". Separators are CSS ::after content — never text nodes in DOM.
breadcrumb when to use
Three or more levels of hierarchy, or when users can arrive from multiple paths. For two levels, use a back link with a ← chevron instead. Never show breadcrumb and back-link simultaneously.
pagination element
<nav aria-label="pagination"> wrapping a <ul>. Current page: aria-current="page". Prev/next: aria-label="Previous page" / "Next page" — arrow characters alone are not announced by AT.
ellipsis
<span class="tbw-pagination-ellipsis" aria-hidden="true">…</span> — not a link. aria-hidden is appropriate when adjacent page numbers provide full context for AT users.
disabled prev/next
When on page 1, replace the prev <a> with <span aria-disabled="true"> — links must have a destination. Same for next on the last page.
<table class="tbw-table"> inside tbw-table-wrapper. Always use <thead> with <th scope="col">. Never omit scope — AT uses it to announce the column header for each cell.
table overflow
Add tabindex="0" + role="region" + aria-label to tbw-table-wrapper. Without tabindex, keyboard users cannot reach the horizontal scroll position on narrow viewports.
list element
tbw-list-unordered / tbw-list-ordered — styled <ul> / <ol>. Do not apply list-style: none without role="list" — Safari VoiceOver drops list semantics when bullets are removed via CSS alone.
definition list
tbw-dl — for key/value pairs: token docs, metadata, spec sheets. Not for decorative metric display — use the stats tile for numeric KPIs.
Compositions
Avatar group
Avatar group — stacked .tbw-avatar elements · negative margin overlap · +N overflow indicator
composition
Flex row of tbw-avatar with margin-left: -10px (md) / -14px (lg). Each gets outline: 2px solid var(--surface) to separate circles. Z-index decrements left to right so the first avatar is on top.
overflow element
+N element: tbw-avatar with data-initials="+5" and background: var(--surface-2). Must carry aria-label="and 5 more". Show 3–4 real avatars before overflow; more than 5 becomes visual noise.
accessible list
Wrap in <div role="list" aria-label="Team members"> with role="listitem" on each. Each avatar needs a title attribute with the person's name.
Tag cluster — .tbw-tag inside .tbw-cluster · three variants: read-only / interactive filter / removable
Read-only — content metadata
design-systemcss-onlytokensbitwise
Interactive — toggle filter
Removable — applied filters
design-systemtokens
read-only
<span class="tbw-tag"> — no pointer cursor, no hover state. Use for content metadata and taxonomy labels only.
interactive (toggle)
<button class="tbw-tag" aria-pressed="true|false">. Never convey active state through color alone — pair with a visible border or background change.
removable
The ✕ is a separate <button aria-label="Remove [tag] filter"> inside the tag span. Touch target: 44×44px on pointer: coarse.
tag vs badge
Tag: user-defined categorical label. Badge: system-assigned status. Never use badge for user content or tag for system status signals.
Contextual notification
Search + filter bar
Search and filter bar — compound molecule · input + icon + filter button + active chips
Simple — input + icon
Compound — input + filter button + active chips
Active:status: drafttype: component
use case
Dashboard list pages where content is filterable — Etch design browser, Curator content queue. Simple variant for single-dimension search. Compound variant when multiple filter dimensions are possible (type, status, date).
input element
Use <input type="search"> — implicit role="searchbox", browser-provided clear button, correct mobile keyboard. Never type="text" for search inputs.
filter button
aria-expanded="false|true" toggles when a filter panel opens. The button must manage focus into the panel on open and return focus to the trigger on close. Icon is aria-hidden="true".
active chip
Each active filter is a tbw-tag span with an embedded <button aria-label="Remove [filter name] filter">. Minimum 44×44px touch target on pointer: coarse. Do not use the ✕ character as the sole label.
Compound variant: the filter button carries aria-expanded — manage focus into the panel on open, return it to this trigger on close. Active chips must have 44px minimum touch targets on coarse-pointer devices. Remove chips when the filter resolves, not just when dismissed — the data and the UI must agree.
Banner
Banner — dismissible inline notification · page-level context · not an alert
role="status" — polite live region. Use for non-urgent informational messages (new features, sync complete). Announces at next idle point. role="alert" — assertive live region. Use for warnings and errors requiring the user's attention. Announces immediately, interrupting current AT reading flow.
banner vs toast vs alert
Banner: persistent inline strip — stays visible until dismissed or resolved. Toast: transient positioned overlay — auto-dismisses. Alert block: semantic block within content flow (form errors). Never substitute one for another.
dismiss button
aria-label="Dismiss [type]" required — the × glyph has no accessible text. On dismiss: remove from DOM or set hidden attribute. Store preference in localStorage so the banner does not reappear on reload.
non-dismissible errors
Error banners representing blocking conditions (payment expired, account suspended) must not be dismissible without resolution. Remove the dismiss button entirely and replace the link with an action that resolves the condition.
Complex, page-level patterns assembled from molecules — navbar, hero, page header, data table, disclosure, feedback, and shell.
Navigation & page structure
Navbar
Navbar — .tbw-header top bar · logo + nav + actions
element
<header class="tbw-header"> — landmark element, not a generic div. Contains logo link, primary nav, and action buttons.
wordmark
Brand name renders in --font-display. Logo must be an <a href="/"> — not a <span>. Wrap in <a class="tbw-header-logo">.
nav links
--font-terminal · letter-spacing 0.04em. Current-page link carries aria-current="page". Color alone does not convey state to AT.
nav label
The inner <nav> must carry aria-label="Primary". When multiple navs exist on a page, every nav must be uniquely labelled.
mobile pattern
At <768px, nav links are hidden and a hamburger button is shown. Hamburger must carry aria-expanded="false|true" and aria-controls referencing the nav element's id. On open, focus moves to the first nav link. Escape closes and returns focus to the hamburger.
Navbar search and filter bar — compound input organism within header context
Use type="search" — not type="text". The browser provides a clear button, AT announces the searchbox role, and mobile keyboards show the correct action key. Filter pills use btn-ghost with [data-active] for selected state. The full compound variant with active chips is in Molecules → Search + filter bar.
Tokens, components, and patterns — one source of truth across every Bitwise product.
The Bitwise Company
Systems that hold.
31 components · 3 brands · deployed to CloudFront CDN
element
<section aria-labelledby="hero-heading"> — landmark section. The id="hero-heading" on the <h1> provides the section's accessible name to AT users navigating by landmark.
heading rule
Hero heading must be <h1>. One <h1> per page. Do not use display-size heading classes on <h2> or lower in a hero context — the visual scale is intentional only on the page title.
gradient
Background: radial-gradient(ellipse 80% 60% at 50% 0%, var(--accent-subtle), transparent) — tracks brand via --accent-subtle. Responds to brand switch automatically.
CTA element
Primary CTA is <a class="tbw-btn-primary"> when it navigates to a URL. Use <button> only if it opens a dialog or triggers JS. Never use onclick on an <a> for navigation — use the href.
variants shown
Centered marketing variant (radial gradient, centered text-6xl, line-height 0.95) and editorial left-aligned variant (asymmetric layout). Both use btn-primary + btn-ghost CTA pair.
motion
Any scroll-indicator animation must be suppressed under prefers-reduced-motion: reduce. Add the block to hero.css when that component file is created.
when to use
First visible content on a marketing or landing page. Not for in-product pages — use tbw-page-header inside the application shell instead.
maturity
Stable. Centered and editorial variants shown; hero.css not yet extracted — styles applied inline in templates.
No dedicated component class yet — assembled from existing tokens and atoms. tbw-page-header is planned (Pass 4 Issue 23-C). This specimen demonstrates the expected composition before the component is extracted.
heading level
Page-header title is an <h1> styled with tbw-h2 — smaller visual weight than a hero, same semantic rank as the primary page heading. Do not use <h2> for the primary title of an in-product page.
breadcrumb
Optional <nav aria-label="breadcrumb"> above the title. Use for three or more hierarchy levels or when the user can arrive from multiple paths. Use a back link for single-level hierarchy. Never show both simultaneously.
action buttons
Primary page action (e.g. "Save changes") uses btn-primary — a <button>, not a link. Right-aligned via flex justify-content: space-between. Secondary actions use btn-ghost.
when to use
Every in-product page that has a title, optional back navigation, and an optional primary action. Not for marketing pages — use the hero component instead.
maturity
In progress. No dedicated tbw-page-header class yet — assembled inline. Extraction planned.
aria-sort="ascending|descending|none" on <th>; sort triggers are <button> inside header — keyboard activatable, announced as sorted column by screen readers
Wrapped in .tbw-table-wrap with overflow-x: auto; table min-width preserved so columns do not collapse — horizontal scroll activates on narrow viewports
Empty state
When <tbody> is empty, insert a single <tr><td colspan="N"> row containing the .tbw-empty-state molecule — table landmark is preserved for screen readers
A named constant representing a design decision — color, spacing, or typography — stored independently of the implementation layer.
Why CSS-only disclosure?
No JavaScript means no hydration cost, no event listeners, no state to synchronize. The browser handles open/closed natively via <details>.
What about accessibility?
The <details> element exposes its open/closed state to assistive technology without needing ARIA roles or properties.
element
<details class="tbw-accordion-item"> + <summary class="tbw-accordion-trigger">. Native <details> exposes open/closed state to AT without ARIA roles. Do not add role="button" to <summary> — it overrides the native disclosure role.
keyboard
Space and Enter toggle open/closed — native <details> behavior, no JS required. Tab moves between accordion triggers. Escape closes in Chromium and Firefox; Safari requires a JS fallback. No arrow-key navigation is expected for the disclosure pattern.
open by default
Add the open attribute to <details> to pre-expand an item. Use sparingly — pre-expanding multiple items defeats the purpose of progressive disclosure.
animation
CSS grid-template-rows: 0fr → 1fr expand animation. Suppressed under prefers-reduced-motion: reduce in accordion.css — verified Pass 1. No JS required for the transition.
UX contract
Multiple sections can be open simultaneously — this is the deliberate choice. <details> items are independent; opening one does not close another. This is appropriate for FAQs and settings where the user may need to compare two open items. If you need single-open-at-a-time behavior (accordion-style mutual exclusion), you must add JavaScript to close other items on open — this library does not do that by default and the trade-off should be made consciously per context.
when to use
Progressive disclosure of secondary content — FAQs, settings groups, supplementary documentation. Not for primary navigation (use tbw-sidebar-nav) or tab-style content switching (use tbw-tabs). Not suitable when the user needs all content visible simultaneously — use a flat section list instead.
Tabs — .tbw-tabs with .tbw-tab-input / .tbw-tab / .tbw-tab-panels · CSS radio-checked trick · no JS
Overview panel. CSS-only tabs — no JavaScript. Radio inputs hold state; :checked ~ .tbw-tab-panels .tbw-tab-panel:nth-child(N) does the show/hide.
Usage: add .tbw-tabs wrapper, then alternating input[type=radio] + label.tbw-tab, then a single .tbw-tab-panels div.
Props panel. Each panel is a .tbw-tab-panel div inside .tbw-tab-panels. Order must match radio order.
technique
CSS-only. input[type=radio] elements hold state; sibling combinators show/hide panels via :checked ~ .tbw-tab-panels .tbw-tab-panel:nth-child(N). No JavaScript required.
UX contract
Exactly one tab panel is visible at a time — this is enforced by the radio input mutual exclusion. Tabs are for content that belongs to the same context but is too long to show simultaneously (overview, usage, props). If the user needs to compare two panels side by side, tabs are the wrong pattern.
when not to use
Do not use tabs for primary page navigation (use tbw-sidebar-nav or tbw-header). Do not use tabs for sequential steps (use a stepper pattern). Do not use tabs when fewer than two panels exist.
keyboard
Tab moves focus to the tab list. Arrow keys navigate between tabs (this requires JS — native radio inputs use Tab between them, not arrows). This implementation uses native radio behavior: Tab moves between labels, Space/click selects.
maturity
Stable. CSS-only; arrow-key navigation requires a small JS enhancement if ARIA tab pattern compliance is required.
Tooltip — .tbw-tooltip-wrapper with .tbw-tooltip [data-position] · CSS :hover and :focus-visible only · no JS
Appears above the triggerAppears to the rightAppears below the triggerAppears to the left
element
.tbw-tooltip-wrapper (position: relative parent) + .tbw-tooltip[role="tooltip"] (absolutely positioned child). The trigger must have aria-describedby pointing to the tooltip's id.
UX contract
Tooltip text is supplementary — it clarifies, it does not instruct. Never put essential information (required field indicators, form error instructions) inside a tooltip. Users on touch devices cannot hover; tooltips must not gate required information.
when not to use
Do not use tooltips to explain a button whose label is already clear. Do not put interactive content (links, inputs) inside a tooltip — use a popover or dialog instead. Do not rely on tooltips for mobile-primary flows.
positions
Default: top. Add data-position="right|bottom|left" to .tbw-tooltip for other placements. No JS viewport collision detection — choose a position that does not clip at expected viewport sizes.
maturity
Stable. CSS :hover only; no focus-visible trigger (requires a JS enhancement for full keyboard support).
Dialog — .tbw-dialog on <dialog> element · Glass 3 elevation · showModal() or HTMX hx-swap
Confirm delete
This action cannot be undone. The component and all its generated variants will be permanently removed from your design system.
element
Production: <dialog class="tbw-dialog"> opened with dialogEl.showModal(). The browser traps focus, provides an implicit Escape close, and prevents background interaction — do not replicate this behavior with a div + overlay.
UX contract
Dialogs are interruptions — they block the underlying page and require an explicit response. Use only for decisions that cannot be deferred: destructive confirmations, critical data entry. For non-blocking information, prefer toast or inline alerts. Dialogs must always have a visible close action.
when not to use
Do not use a dialog for informational messages (use toast). Do not use a dialog for large forms with many fields (use a page or drawer). Do not nest dialogs.
HTMX pattern
Swap the inner content of an existing <dialog> via hx-target + hx-swap="innerHTML", then call showModal() from an htmx:afterSwap event listener.
maturity
Stable. Static sim shown; wire to native <dialog> in production.
Production: <dialog class="tbw-drawer" data-side="right">. Like dialog, showModal() traps focus. Use data-side="left|right|bottom" for placement. Close on Escape is native.
UX contract
Drawers are for supplementary tasks that extend the current context — editing settings, viewing details, filtering results — without losing the user's place in the underlying page. If the task requires full attention or multiple steps, use a separate page instead.
when not to use
Do not use a drawer for destructive confirmations (use dialog). Do not use a drawer on mobile when it would cover the entire screen — use a bottom sheet or page navigation instead.
maturity
Stable. Static sim shown; wire to native <dialog> in production.
In production: <div id="toast-region" class="tbw-toast-region"> as HTMX hx-swap-oob target. The region is fixed to the viewport. Individual toasts are injected and removed by the server response.
UX contract
Toasts are non-blocking confirmations of completed actions — they appear, persist briefly, and disappear without requiring user input. Write toast copy in the past tense: "Saved.", "Exported." Never use a toast for errors that require user action — those belong in inline alerts or dialogs.
copy rule
State what happened, not what is happening. Correct: "Token export complete — 47 variables written." Incorrect: "Exporting tokens…" (that is a loading state, not a toast). Keep to one sentence. For errors that require no action: "Failed to connect — try again." For errors that require action, use an inline alert.
Title: state the absence plainly — "No components yet", not "Nothing here." Body: explain what will appear and how to create it. CTA: use a specific verb — "Generate first component", not "Get started." Never use empty states as error messages — errors need their own alert component.
when not to use
Do not show an empty state while data is loading — show a skeleton instead. Do not use empty state for error conditions (network failure, permission denied) — those need distinct error state components with specific recovery actions.
First focusable element in the DOM: <a class="tbw-skip-nav" href="#main-content">Skip to main content</a>. Visually hidden until focused; keyboard users bypass repeated navigation on every page load.
At 767px sidebar collapses to zero width; a hamburger trigger or bottom nav exposes it via drawer. Header remains full-width. .tbw-content spans full width below the header breakpoint.
Account
The necessary surfaces — passwordless sign-in (passkey → email → SMS last), account menu with only Profile and Sign out, legal footer. Icons and marks come from svg/library.svg. Designed in design/account.board.svg.
Prototype mode — click "Sign in" or "Get started" on the landing screen to transition to the auth flow within the same frame. Click "Go to dashboard" after signing in to reach the Etch dashboard. All transitions are in-frame opacity fades — no scroll jumps. Bitwise: grain surface (SVG fractalNoise) active. Curator linen and Etch slate surfaces: planned for a future tokens.css layer addition.
CSS-native. No runtime. Semantic by default. Two stylesheet imports and your product speaks the brand.
Token-first
Semantic by default.
No raw hex values in product CSS. Ever. Every color, space, and radius is a named token typed with CSS @property.
Multi-brand
One attribute. Three identities.
Set data-brand on any element. Bitwise, Curator, and Etch resolve independently — no class conflicts, no overrides.
No build step
CSS-native. No runtime.
Two stylesheet imports. Works in any stack. Handlebars partials for HTML structure — fetch them from the CDN, render with your engine.
Bitwise at page scale. The radial gradient is --accent-subtle — at 5% opacity it reads as warmth, not color. The noise layer sits above the gradient at 3% opacity. Together they give the flat dark surface physical weight without adding a hue. data-brand="bitwise" on the inner wrapper pins the token context regardless of the global brand switcher — this is the production pattern for brand-scoped frames within a larger document.
Sign in
Sign in — auth form · interactive state machine · validation + loading + success states
Prototype pattern — demonstrates auth form composition: loading spinner, opacity+transform state transition driven by --duration-base + --ease-out tokens. JS only toggles classes — CSS handles all animation. Production: add CSRF token, rate limiting, and error state handling.
Prototype pattern — demonstrates Etch app shell: sidebar nav with token-driven active state transitions, stats row, card grid with selection ring, and creation overlay (glass backdrop). Production: sidebar via HTMX hx-get; overlay as <dialog> with showModal().
Content page
Content page — docs shell · sticky header · sidebar nav · scroll-spy table of contents
The button component provides primary and ghost variants with full token coverage across all interaction states — hover, focus, active, and disabled.
Buttons communicate affordance through visual weight: btn-primary signals the most important action; btn-ghost recedes to support it.
Usage
Apply .btn-primary for the primary CTA. Use .btn-ghost for secondary actions that should recede visually.
Reserve primary for the single most important action. Use ghost for cancel, secondary nav, and destructive confirmations.
Disabled state
Use data-disabled + aria-disabled="true" instead of the native disabled attribute when the button must remain focusable for screen reader announcement.
Icon buttons
For icon-only buttons, always provide an aria-label. Never rely on the icon alone to communicate the action to assistive technology users.
Props
Button exposes two CSS classes and a set of data attributes for state management:
.btn-primary — filled accent background, high contrast label
.btn-ghost — transparent background, border on hover
[data-disabled] — visual disabled state at 0.45 opacity
All states are token-driven. Hover, focus-visible, and active are CSS pseudo-class rules inside @layer components.
Examples
Rendered specimens show every variant and state combination in the current brand context. Switch the brand selector in the showcase sidebar to see the same component in Curator or Etch identity.
Leading-icon pattern: wrap button content in a flex container and include a 14x14 SVG with aria-hidden="true" before the label text. Icon-only pattern: set equal width and height on the button, use aspect-ratio:1, and provide a descriptive aria-label.
Prototype pattern — demonstrates documentation shell: sticky product header, two-level left nav (component list + on-page TOC), scroll-spy via IntersectionObserver, breadcrumb, live button specimens, and a copy-to-clipboard code block. Production: scroll-spy on IntersectionObserver with 0.6 threshold; left nav loaded via HTMX partial.