01 — Navigation
Breadcrumb
Use nav with aria-label="Breadcrumb" and an ordered list ol.breadcrumb. Each segment is a li; use links for parents and plain text for the current page. Separators are added with CSS (li + li::before). The last item is styled as the current page.
<nav aria-label="Breadcrumb">
<ol class="breadcrumb">
<li>
<a href="/" aria-label="Home">
<svg class="icon" aria-hidden="true">
<use href="downstage-icons.svg#home" />
</svg>
</a>
</li>
<li><a href="/docs">Documentation</a></li>
<li>Current page</li>
</ol>
</nav>
02 — Components
Buttons
<div class="cluster">
<button class="btn">Default</button>
<button class="btn btn-primary">
<svg class="icon"><use href="downstage-icons.svg#save" /></svg>
Save
</button>
<button class="btn btn-secondary">
<svg class="icon"><use href="downstage-icons.svg#filter" /></svg>
Filter
</button>
<button class="btn btn-ghost">Ghost</button>
<button class="btn btn-danger">
<svg class="icon"><use href="downstage-icons.svg#trash" /></svg>
Delete
</button>
<button class="btn btn-danger-solid">
<svg class="icon"><use href="downstage-icons.svg#x" /></svg>
Confirm
</button>
<button class="btn btn-success">
<svg class="icon"><use href="downstage-icons.svg#check" /></svg>
Approve
</button>
<button class="btn" disabled>Disabled</button>
</div>
Sizes
<button class="btn btn-sm btn-primary">Small</button>
<button class="btn btn-primary">Default</button>
<button class="btn btn-lg btn-primary">Large</button>
03 — Components
Forms
Create account
Card form with text inputs, select, checkbox, switch, and validation states.
Create account
Demo<div class="card">
<div class="card-header">
<h4 style="margin: 0;">
<svg class="icon"><use href="downstage-icons.svg#user-plus" /></svg>
Create account
</h4>
<span class="badge">Demo</span>
</div>
<div class="stack">
<div class="field">
<label class="label" for="username">Username</label>
<input id="username" class="input" type="text" placeholder="username">
</div>
<div class="field">
<label class="label" for="email">Email</label>
<input id="email" class="input" type="email" placeholder="you@example.com">
<span class="help">We will never share your email.</span>
</div>
<div class="field has-error">
<label class="label" for="password">Password</label>
<input id="password" class="input" type="password">
<span class="error">At least 8 characters.</span>
</div>
<div class="field">
<label class="label" for="role">Role</label>
<select id="role" class="select">
<option>Developer</option>
<option>Designer</option>
</select>
</div>
<label class="check">
<input type="checkbox" checked>
<span>I accept the terms of service</span>
</label>
<div class="cluster" style="justify-content: space-between;">
<span class="text-subtle">Auto-save enabled</span>
<label class="switch">
<input type="checkbox" checked>
<span class="switch-slider"></span>
</label>
</div>
</div>
<div class="card-footer">
<button class="btn btn-secondary">Cancel</button>
<button class="btn btn-primary">
<svg class="icon"><use href="downstage-icons.svg#save" /></svg>
Save
</button>
</div>
</div>
Minimal underline
Single bottom border, transparent to the page background (--bg).
<form class="stack">
<div class="field">
<label class="label" for="name">Name</label>
<input id="name" class="input input-minimal" type="text" placeholder="Your name">
</div>
<div class="field">
<label class="label" for="email">Email</label>
<input id="email" class="input input-minimal" type="email" placeholder="you@example.com">
</div>
<div class="field">
<label class="label" for="topic">Topic</label>
<select id="topic" class="select select-minimal">
<option>General</option>
<option>Support</option>
</select>
</div>
<div class="field">
<label class="label" for="message">Message</label>
<textarea id="message" class="textarea textarea-minimal" placeholder="Brief message"></textarea>
</div>
<div class="field">
<span class="label">Attachment</span>
<div class="upload-drop" data-upload-drop>
<input class="sr-only" type="file" multiple>
<div class="upload-drop-zone" tabindex="0" role="button">
<svg class="icon icon-lg" aria-hidden="true">
<use href="downstage-icons.svg#upload" />
</svg>
<span class="upload-drop-text">Drop files here or <label class="upload-drop-browse">browse</label></span>
<span class="help">PDF, PNG</span>
</div>
<ul class="upload-drop-list" hidden></ul>
</div>
</div>
<div class="cluster mt-2" style="justify-content: flex-end;">
<button type="button" class="btn btn-ghost">Cancel</button>
<button type="submit" class="btn btn-primary">Send</button>
</div>
</form>
03a — Components
Input prefix & suffix
Wrap the control in .input-affix and add
.input-affix__prefix and/or .input-affix__suffix
for non-editable labels (scheme, currency, units). The inner field keeps .input;
focus ring applies to the whole group via :focus-within.
<div class="field">
<label class="label" for="url">Website</label>
<div class="input-affix">
<span class="input-affix__prefix">https://</span>
<input id="url" class="input" type="text" placeholder="example.com">
</div>
</div>
<div class="field">
<label class="label" for="amt">Amount</label>
<div class="input-affix">
<input id="amt" class="input" type="text" placeholder="0,00">
<span class="input-affix__suffix">€</span>
</div>
</div>
03b — Components
Form validation (JS)
downstage.js includes a small helper:
add data-form-validate on a <form> (or call
Downstage.formValidate.mount(form, options)). It checks
required, type="email",
minlength, and pattern, toggles
.field.has-error, and fills a .error node when present.
Messages come from i18n (formValidate.*) or from
data-error-* on the .field.
<form id="demo-form-validation" data-form-validate novalidate>
<div class="field">
<label class="label" for="name">Name</label>
<input id="name" class="input" type="text" name="name" required minlength="2">
<span class="error" aria-live="polite"></span>
</div>
<div class="field">
<label class="label" for="email">Email</label>
<input id="email" class="input" type="email" name="email" required>
<span class="error" aria-live="polite"></span>
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
Put each control in .field with a sibling
.error for messages. Use novalidate so the script controls display.
// Auto: any form with data-form-validate on DOMContentLoaded
// Manual:
Downstage.formValidate.mount(document.querySelector('#my-form'), {
live: true,
onValid: function (e) { e.preventDefault(); /* fetch… */ }
});
// Field API (e.g. after dynamic value set):
Downstage.formValidate.validateField(document.querySelector('#email'));
| Option | Type | Default | Description |
|---|---|---|---|
live |
boolean | true |
Re-validate on input/blur after an error was shown. |
onValid |
function | — | If the form passes validation on submit, called as onValid.call(form, event); use preventDefault to handle async submit. |
Per-field message overrides on .field or input:
data-error-required, data-error-email,
data-error-minlength, data-error-pattern.
Otherwise strings come from i18n formValidate.*.
Validated constraints: required (including checkboxes),
type="email", minlength, pattern.
03c — Components
Date fields
Native date and datetime-local inputs,
plus a date range pair with automatic min / max constraints between the two fields.
Date input
A single date field. Add data-datepicker-type="datetime" for date+time.
<!-- Date only -->
<div class="field">
<label class="label" for="birthday">Date only</label>
<div data-datepicker data-datepicker-name="birthday">
<input id="birthday" class="input" type="date">
</div>
</div>
<!-- Date + time -->
<div class="field">
<label class="label" for="appt">Date + time</label>
<div data-datepicker data-datepicker-type="datetime" data-datepicker-name="appointment">
<input id="appt" class="input" type="datetime-local">
</div>
</div>
Date range
Two linked fields — selecting a From date automatically sets
min on the To field (and vice-versa). Works with both
date and datetime.
Try picking a From date — the To field will prevent earlier dates.
<!-- Date range (date only) -->
<div data-daterange
data-daterange-from-name="start_date"
data-daterange-to-name="end_date"></div>
<!-- Datetime range -->
<div data-daterange
data-daterange-type="datetime"
data-daterange-from-name="start_at"
data-daterange-to-name="end_at"></div>
<!-- With global min / max boundaries -->
<div data-daterange
data-daterange-min="2026-01-01"
data-daterange-max="2026-12-31"
data-daterange-from-name="from"
data-daterange-to-name="to"></div>
// Programmatic mount
var range = Downstage.dateRange.mount('#my-range', {
type: 'datetime', // 'date' (default) or 'datetime'
fromName: 'start_at',
toName: 'end_at',
min: '2026-01-01', // global floor
max: '2026-12-31', // global ceiling
fromLabel: 'Check-in', // custom labels
toLabel: 'Check-out',
});
// API
range.getFrom(); // '2026-04-09'
range.getTo(); // '2026-04-12'
range.setFrom('2026-05-01');
range.setTo('2026-05-07');
range.setMin('2026-04-01');
range.setMax('2026-06-30');
// Event
el.addEventListener('daterange-change', function (e) {
console.log(e.detail.from, e.detail.to);
});
Datetime range
Same component with data-daterange-type="datetime".
<div data-daterange
data-daterange-type="datetime"
data-daterange-from-name="start_at"
data-daterange-to-name="end_at"></div>
04 — Components
Copyable fields
Read-only or disabled values with a copy action (.input-copy-wrap + downstage.js). Use .input-copy-wrap-minimal for underline controls on the page background.
<!-- Read-only textarea with copy -->
<div class="field">
<label class="label">Read-only textarea</label>
<div class="input-copy-wrap">
<textarea class="textarea" readonly rows="4">Content here</textarea>
<button type="button" class="input-copy-btn">
<svg class="icon icon-sm" aria-hidden="true">
<use href="downstage-icons.svg#copy" />
</svg>
<span class="input-copy-btn-label">Copy</span>
</button>
</div>
</div>
<!-- Disabled input with copy -->
<div class="field">
<label class="label">Disabled input</label>
<div class="input-copy-wrap">
<input class="input" type="text" value="..." disabled>
<button type="button" class="input-copy-btn">
<svg class="icon icon-sm" aria-hidden="true">
<use href="downstage-icons.svg#copy" />
</svg>
<span class="input-copy-btn-label">Copy</span>
</button>
</div>
</div>
<!-- Minimal textarea with copy -->
<div class="field">
<label class="label">Minimal textarea</label>
<div class="input-copy-wrap input-copy-wrap-minimal">
<textarea class="textarea textarea-minimal" readonly rows="4">Content</textarea>
<button type="button" class="input-copy-btn">
<svg class="icon icon-sm" aria-hidden="true">
<use href="downstage-icons.svg#copy" />
</svg>
<span class="input-copy-btn-label">Copy</span>
</button>
</div>
</div>
<!-- Minimal disabled input with copy -->
<div class="field">
<label class="label">Minimal disabled input</label>
<div class="input-copy-wrap input-copy-wrap-minimal">
<input class="input input-minimal" type="text" value="..." disabled>
<button type="button" class="input-copy-btn">
<svg class="icon icon-sm" aria-hidden="true">
<use href="downstage-icons.svg#copy" />
</svg>
<span class="input-copy-btn-label">Copy</span>
</button>
</div>
</div>
05 — Components
Alerts
Contextual feedback messages. Use .alert with a variant class (.alert-info, .alert-success, .alert-warning, .alert-danger).
<div class="alert alert-info">
<svg class="icon icon-lg"><use href="downstage-icons.svg#info" /></svg>
<div><strong>Info</strong> — Informational message.</div>
</div>
<div class="alert alert-success">
<svg class="icon icon-lg"><use href="downstage-icons.svg#check-circle" /></svg>
<div><strong>Success</strong> — Changes saved.</div>
</div>
<div class="alert alert-warning">
<svg class="icon icon-lg"><use href="downstage-icons.svg#alert" /></svg>
<div><strong>Warning</strong> — Session expires soon.</div>
</div>
<div class="alert alert-danger">
<svg class="icon icon-lg"><use href="downstage-icons.svg#x-circle" /></svg>
<div><strong>Error</strong> — Operation failed.</div>
</div>
06 — Components
Badges
Inline status labels. Use .badge with an optional variant class.
<span class="badge">Default</span>
<span class="badge badge-success">Success</span>
<span class="badge badge-danger">Danger</span>
<span class="badge badge-warning">Warning</span>
<span class="badge badge-info">Info</span>
07 — Components
File cards
Downloadable file links with icon, title, and metadata. Use a.file-card.
<a href="/files/document.pdf" class="file-card">
<span class="file-card-icon">
<svg class="icon icon-xl"><use href="downstage-icons.svg#file-pdf" /></svg>
</span>
<div class="file-card-body">
<div class="file-card-title">document.pdf</div>
<div class="file-card-meta">2.4 MB · 12 pages</div>
</div>
<svg class="icon"><use href="downstage-icons.svg#download" /></svg>
</a>
08 — Components
Table
Default in card, then minimal on --bg, compact, striped, and combined variants.
<div class="card" style="padding: 0; overflow: hidden;">
<div class="table-wrap" role="region" aria-label="Table" tabindex="0">
<table class="table">
<thead>
<tr>
<th>Project</th>
<th>Status</th>
<th>Owner</th>
<th class="text-right">Build</th>
</tr>
</thead>
<tbody>
<tr>
<td>App</td>
<td><span class="badge badge-success">Stable</span></td>
<td>team</td>
<td class="text-right text-mono">#1284</td>
</tr>
</tbody>
</table>
</div>
</div>
Minimal (page background)
No card: only bottom borders on rows, cells sit on --bg like minimal inputs. Class table table-minimal.
| Project | Status | Owner | Build |
|---|---|---|---|
| cipi.sh v4.2.1 |
Stable | team | #1284 |
| init scaffolder |
Beta | team | #0421 |
| downstage.css design system |
RC | team | #0042 |
<div class="table-wrap" role="region" aria-label="Table" tabindex="0">
<table class="table table-minimal">
<thead>
<tr>
<th>Project</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr>
<td>App</td>
<td><span class="badge badge-success">Stable</span></td>
</tr>
</tbody>
</table>
</div>
Compact
Tighter padding — table table-compact inside a card.
<div class="card" style="padding: 0; overflow: hidden;">
<div class="table-wrap">
<table class="table table-compact">
<thead>
<tr>
<th>Key</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-mono">theme</td>
<td>light</td>
</tr>
</tbody>
</table>
</div>
</div>
Striped
Alternating rows — table table-striped.
<div class="card" style="padding: 0; overflow: hidden;">
<div class="table-wrap">
<table class="table table-striped">
<thead>
<tr>
<th>Event</th>
<th>Time</th>
</tr>
</thead>
<tbody>
<tr>
<td>Deploy</td>
<td class="text-mono">09:41</td>
</tr>
<tr>
<td>Build OK</td>
<td class="text-mono">09:38</td>
</tr>
</tbody>
</table>
</div>
</div>
Minimal + compact
Combine modifiers: table table-minimal table-compact.
| Setting | Value |
|---|---|
| Font | Space Grotesk |
| Radius | 8px |
| Container | 72ch |
<div class="table-wrap" role="region" aria-label="Table" tabindex="0">
<table class="table table-minimal table-compact">
<thead>
<tr>
<th>Setting</th>
<th class="text-right">Value</th>
</tr>
</thead>
<tbody>
<tr>
<td>Font</td>
<td class="text-right text-mono">Space Grotesk</td>
</tr>
</tbody>
</table>
</div>
09 — Components
HTML editor
Toolbar on contenteditable with
document.execCommand and a link dialog (mailto,
tel:, target, rel). Optional: rich text / HTML
source switch via showRawSwitch; hide toolbar buttons with
toolbarExclude. "H7" is <p class="ds-h7">
with
role="heading" / aria-level="7". Initial HTML:
inside
data-html-editor-mount or initialHtml in JS.
Select text and use the toolbar to format.
<div class="html-editor" data-html-editor-mount>
<p>Initial content goes here.</p>
</div>
Downstage.htmlEditor.mount(element, {
showRawSwitch: true,
toolbarExclude: ['h7'],
initialHtml: '<p>Hello world</p>'
});
10 — Components
Timeline
Vertical ordered list with a line and markers. Use <ol class="timeline">;
each entry is a .timeline-item with .timeline-marker and
.timeline-content.
-
First public release
Single CSS, icon sprite, and JS with built-in components for tabs, accordion, and lightbox.
-
Portfolio e blog
Project cards and articles with a responsive grid and text variants.
Release -
Timeline e team
New blocks for timelines, profiles, and "link in bio" pages — still zero dependencies.
Shipped -
Documentation and themes
Variable guide, dark-theme examples, and copy-paste snippets for the most-used components.
Planned
<ol class="timeline" aria-label="Roadmap">
<li class="timeline-item">
<span class="timeline-marker" aria-hidden="true"></span>
<div class="timeline-content">
<time class="timeline-date" datetime="2025-06">Jun 2025</time>
<h3 class="timeline-title">Title</h3>
<p class="timeline-desc">Description text.</p>
<span class="timeline-badge"><span class="badge">Label</span></span>
</div>
</li>
</ol>
Compact variant
Add .timeline--compact for tighter line spacing.
-
Accessibility audit
Visible focus states and contrast checks on interactive components.
-
Static examples
Single demo page as a visual reference.
<ol class="timeline timeline--compact" aria-label="Milestones">
<li class="timeline-item">
<span class="timeline-marker" aria-hidden="true"></span>
<div class="timeline-content">
<time class="timeline-date" datetime="2026-04">Apr 2026</time>
<h3 class="timeline-title">Title</h3>
<p class="timeline-desc">Description text.</p>
</div>
</li>
</ol>
11 — Components
Mini map
A compact map for contact or venue blocks: same stack as
Data UI → Map — Leaflet + CARTO / OSM (no Google Maps).
The marker uses a custom L.divIcon (CSS pin + brand color).
In a card
Title and address above the map, single bordered block.
Showroom
Minimal (unboxed)
Map strip only: no card chrome, no outer border; standard (smaller) pin.
<!-- In a card -->
<div class="ds-map-card">
<div class="ds-map-card-body">
<h3 class="ds-map-card-title">Venue</h3>
<p class="ds-map-card-meta">Address line</p>
</div>
<div id="venue-map" class="ds-map-demo ds-map-demo--card"></div>
</div>
<!-- Minimal: map container only -->
<div id="venue-map-minimal" class="ds-map-demo ds-map-demo--minimal"></div>
var map = L.map('venue-map', {
scrollWheelZoom: false,
attributionControl: true
}).setView([45.4642, 9.19], 15);
L.tileLayer(
'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png',
{ attribution: '…', subdomains: 'abcd', maxZoom: 20 }
).addTo(map);
var pin = L.divIcon({
className: 'ds-leaflet-marker-wrap',
html: '<div class="ds-leaflet-marker-pin ds-leaflet-marker-pin--card" ' +
'style="--pin-color:var(--brand-primary)"></div>',
iconSize: [40, 48],
iconAnchor: [20, 48],
popupAnchor: [0, -44]
});
L.marker([45.4642, 9.19], { icon: pin })
.addTo(map)
.bindPopup('We are here');