Components¶
@import 'nilam/components.css'; /* the .n-* layer */
@import 'nilam/widgets.css'; /* combobox + slider + range, needs nilam/behaviours */
Every example on this page is real markup, rendered by the stylesheet npm publishes. If a demo looks wrong here it is wrong in your app.
The scope, stated honestly¶
This does not compete with Radix or React Aria on breadth and it never will. React Aria is years of screen-reader, touch and IME work by a team; a stylesheet cannot contain that.
What a stylesheet can do in 2026 is much more than it could in 2020, because the platform grew the hard parts:
| Component | Built on | So nilam ships |
|---|---|---|
.n-dialog |
<dialog> + showModal() |
no focus-trap library |
.n-pop, .n-menu |
Popover API + CSS anchor positioning | no positioning library |
.n-tip |
popover="hint" |
nothing extra |
.n-accordion |
<details name> |
no state at all |
.n-textarea |
field-sizing: content |
no scroll-height listener |
.n-field |
:has() |
no wrapper class toggled by JS |
Deliberately absent: date picker, rich text, virtualised table, tree, drag-and-drop. Each needs a real state machine and, more to the point, real assistive-technology testing to be worth shipping.
The rule every status component follows¶
The prover measures which status colours collapse under each dichromacy and reports that at
hue 285, danger/warn, danger/ok and warn/ok all become indistinguishable to a
deuteranope. So every status component here carries a glyph as well as a hue, and
proveStatusChannels() fails the build if one does not.
WCAG 1.4.1 has required this for years. Colour blindness has the measurements.
Buttons¶
<button class="n-btn n-btn-fill">Primary</button>
<button class="n-btn">Secondary</button>
<button class="n-btn n-btn-ink">Ink</button>
<button class="n-btn n-btn-ghost">Ghost</button>
<button class="n-btn n-btn-danger">Delete</button>
<button class="n-btn n-btn-icon" aria-label="Add">+</button>
<button class="n-btn n-btn-fill" aria-busy="true">Saving</button>
Four things here are decisions rather than styling.
Disabled does not use opacity. Fading a button fades its label too and drops it below
4.5:1 — the most common accessibility bug in every component library checked. Disabled is
expressed with --neutral-2 / --neutral-6 / --neutral-11, which are all still legible.
It covers :disabled and [aria-disabled="true"], because :disabled does not match an
anchor and frameworks disagree about which they set.
The pointer target is 44px and the painted box is 36px. WCAG 2.5.8 wants 44; a 44px-tall
button looks chunky. The shortfall is made up by a transparent ::after, so the target is
compliant and layout is untouched.
The busy label goes transparent rather than being removed, so the button keeps its width and the row does not reflow when it starts loading.
The busy spinner names its own colour per variant. The first version set color to
--brand-ink on the base rule, which is near-white in light mode — so a ghost button's
spinner was white on a transparent background and simply invisible.
Loaders¶
Four, because they answer four different questions, and substituting one for another is a real usability error.
| Says | Use when | |
|---|---|---|
.n-spinner |
something is happening | duration unknown, inline, small |
.n-progress |
this much is done | you know the proportion — it is a real <progress> |
.n-bar |
something is happening | duration unknown, page or panel width |
.n-skeleton |
content shaped like this is coming | the shape is predictable |
spinner
with a status wrapper
Checking your card…dots
Generatingprogress
bar
skeleton
<span class="n-spinner n-spinner-sm"></span>
<span class="n-loading" role="status">
<span class="n-spinner n-spinner-sm"></span>Checking your card…
</span>
<span class="n-dots"><i></i><i></i><i></i></span>
<progress class="n-progress" value="0.62" max="1">62%</progress>
<div class="n-bar" role="progressbar" aria-label="Loading"></div>
<div class="n-skeleton" style="block-size:1.25rem;inline-size:40%"></div>
Prefer the skeleton wherever the shape is predictable. A spinner tells the reader nothing about what is arriving; a skeleton tells them where to look when it does.
.n-loading carries role="status", which has an implicit aria-live="polite", so the
text inside is announced once without interrupting. The visible loader beside it is
decorative — which is why the examples pair them.
Loaders keep moving under prefers-reduced-motion
The base layer's blanket rule sets animation-duration: 0.01ms on everything, and for a
loader that is actively harmful: a frozen spinner does not read as reduced motion, it
reads as a hung app. The reader loses the only signal that anything is still
happening.
WCAG 2.3.3 governs non-essential animation, and a loading indicator is essential by definition, so an exemption is correct rather than a loophole. What these keep is the one property that carries the signal — opacity, which moves nothing across the screen and does not trigger vestibular symptoms. The travelling bar becomes a pulse in place; the spinner stops rotating and breathes instead.
Fields¶
We only use this for receipts.
Enter a valid email address.
checkbox — including the tri-state
<div class="n-field">
<label class="n-label" data-required for="email">Billing email</label>
<input class="n-input" id="email" type="email" required aria-describedby="hint">
<p class="n-hint" id="hint">We only use this for receipts.</p>
</div>
<p class="n-error">Enter a valid email address.</p>
<label class="n-choice"><input class="n-check" type="checkbox" checked> Checked</label>
<label class="n-choice"><input class="n-radio" type="radio" name="billing"> Yearly</label>
<label class="n-choice"><input class="n-switch" type="checkbox"> Weekly digest</label>
Placeholders are step 11, not a lighter grey. Placeholder text is text and 1.4.3 applies to it; most systems ship placeholders around 2.5:1.
Invalid uses :user-invalid, not :invalid. :invalid paints an empty required field
red before it has been touched, which is hostile. :user-invalid waits until the user has
finished.
--danger-8 for the invalid border, and .n-error carries a ⚠ glyph. Danger is one of
the colours the prover says collapses, so colour alone is not allowed to carry "this is
wrong".
The select arrow is drawn in currentColor. A hard-coded SVG fill is the usual approach
and it is invisible in dark mode.
The required marker is a hue and a glyph (*), and the input's own required is what
assistive technology reads.
.n-check:indeterminate is a real tri-state that most libraries forget. It has no HTML
attribute — it is a DOM property — which is why the demo above needs one line of JavaScript
to show it.
Cards¶
Default
--surface, a hairline, --shadow-1 and --rim.
Floating
--shadow-2 instead.
A link card
Lifts 2px and escalates to --shadow-3 on hover.
<div class="n-card n-card-pad">…</div>
<div class="n-card n-card-pad n-card-float">…</div>
<a class="n-card n-card-pad n-card-link" href="/thing">…</a>
The background is --surface, not --neutral-1. In light mode the page is tinted and the
card is pure white; the tint reads as light because the card beside it has none. See
the untinted card surface.
Badges¶
<span class="n-badge n-badge-ok"><i class="n-badge-glyph">✓</i>Active</span>
<span class="n-badge">Neutral</span>
Step 3 for the fill, step 6 for the border, step 11 for the text, step 9 + ink for the glyph. Four roles, four steps, and each one is the step whose contract matches the job.
Notes¶
<div class="n-note n-note-warn">
<span class="n-note-glyph">!</span>
<div><span class="n-note-title">Card expires next month.</span> Update it before 1 September.</div>
</div>
The glyph is 1.25rem, round, filled with step 9 and inked with --<family>-ink. Its
margin-block-start: 0.0625rem is optical alignment with the first line of body text rather
than with the top of the box.
Tables¶
| Account | Owner | Status | MRR |
|---|---|---|---|
| Northwind | NW | ✓Active | £4,200 |
| Contoso | CO | !Expiring | £2,750 |
| Initech | IN | ×Failed | £1,980 |
| Umbrella | UM | iTrial | £0 |
<div class="n-table-scroll" tabindex="0">
<table class="n-table">
<thead><tr><th>Account</th><th class="n-table-num">MRR</th></tr></thead>
<tbody>
<tr><td class="n-table-key">Northwind</td><td class="n-table-num">£4,200</td></tr>
</tbody>
</table>
</div>
th gets the label treatment — mono, uppercase, --tracking-micro — and a step-7 rule
under it, while rows are divided by step 6. The head is a stronger boundary than a row
division, and using one weight for both is exactly why default tables read as a grid of
boxes.
.n-table-num gives tabular-nums. Nothing else makes a table look as unfinished as
proportional digits in a column.
tabindex="0" on the scroll wrapper is a real 2.1.1 requirement that people miss: a
scrollable region has to be scrollable by keyboard.
Avatars¶
<span class="n-avatar">MD</span>
<span class="n-avatar n-avatar-lg"><img src="…" alt="Ada Lovelace"></span>
Meters¶
brand, ok, warn, danger
<div class="n-meter"><div class="n-meter-fill" style="inline-size:78%"></div></div>
<div class="n-meter n-meter-ok"><div class="n-meter-fill" style="inline-size:62%"></div></div>
A plain div, because a meter is often a proportion of a whole rather than task progress.
Add role="progressbar" and aria-valuenow yourself when it genuinely is one — or use
.n-progress, which is a real <progress> and puts the value in the accessibility tree for
free.
Dialog¶
Native <dialog> with showModal(). That one call buys the focus trap, Esc to close, an
inert background and ::backdrop — so there is no focus-trap library here.
the real thing — modal, Esc closes it
the same markup, pinned into the page so it is visible here
The second one is <dialog open>, which is non-modal: no focus trap, no Esc, no inert background. Two CSS lines pin it into the flow. It is here for the screenshot; use showModal().
<button class="n-btn n-btn-fill" onclick="document.getElementById('d').showModal()">
Open dialog
</button>
<dialog class="n-dialog" id="d">
<form method="dialog">
<div class="n-dialog-head"><h3 class="n-dialog-title">Delete workspace</h3></div>
<div class="n-dialog-body">…</div>
<div class="n-dialog-foot">
<button class="n-btn" value="cancel">Cancel</button>
<button class="n-btn n-btn-danger" value="delete">Delete for ever</button>
</div>
</form>
</dialog>
The enter and exit animation needs three separate features and missing any one of them
silently kills the exit: transition-behavior: allow-discrete to animate display at all,
@starting-style for the entry values, and overlay so the element stays in the top layer
while it leaves.
There is no z-index on .n-dialog. The top layer already handles stacking, and no
z-index can reach into it.
Popover, menu and tooltip¶
real popovers — no JavaScript, no positioning library
the same menu, pinned open so it is visible here
<button class="n-btn" popovertarget="m">Actions ▾</button>
<div class="n-pop n-menu" popover id="m">
<button class="n-menu-item">Duplicate</button>
<div class="n-menu-sep"></div>
<button class="n-menu-item n-menu-item-danger">Delete</button>
</div>
<button class="n-btn" popovertarget="t" popovertargetaction="toggle">Tooltip</button>
<div class="n-tip" popover id="t">Positioned by CSS, not by JavaScript.</div>
A popover opened by popovertarget gets an implicit anchor reference to the button that
opened it. So position-area: block-end span-inline-start positions it against its own
trigger with no anchor-name anywhere and no Floating UI, and
position-try-fallbacks: flip-block, flip-inline handles running out of viewport.
.n-tip uses popover="hint", which is the right type: a hint does not close other
popovers and does not take focus. Where hint is unsupported it falls back to a plain
popover.
Arrow keys, typeahead and focus restoration on the menu come from
nilam/behaviours. The opening, the light dismiss and Esc are the
platform's.
Accordion¶
Why is info achromatic?
Why is step 9 different in each mode?
What is not derived?
GLOW_L = 0.66. Four derivations were tried and none produce it. It is measured off Zima Blue and an accent that works.<div class="n-accordion">
<details name="faq" open>
<summary class="n-summary">Why is info achromatic?</summary>
<div class="n-accordion-body">…</div>
</details>
<details name="faq">…</details>
</div>
<details name> makes the group exclusive — opening one closes the others — with no
JavaScript and no state to manage.
.n-summary is display: block with the chevron absolutely positioned, and that is a bug
fix rather than a preference. Flex with space-between is the obvious way to push a marker
to the end, and flex turns every text node and every inline element into a separate flex
item: the summary above, which contains a <code>, came out as three items spread across
the full width with gaps in the middle of the sentence.
Tabs¶
Arrow keys move between tabs. Home and End jump to the ends.
Activation is automatic by default: arrowing to a tab selects it.
Pass { manual: true } when a panel does real work.
<div class="n-tabs" role="tablist">
<button class="n-tab" role="tab" aria-selected="true" aria-controls="p1" id="t1">Overview</button>
<button class="n-tab" role="tab" aria-selected="false" aria-controls="p2" id="t2">Usage</button>
</div>
<div id="p1" role="tabpanel" aria-labelledby="t1">…</div>
<div id="p2" role="tabpanel" aria-labelledby="t2" hidden>…</div>
aria-selected is the state, and the CSS selects on it. There is no active class. If the
markup is not accessible, the styling does not apply — a useful forcing function.
Combobox¶
The one genuinely missing widget: nothing native does it. It needs
nilam/behaviours and nilam/widgets.css.
editable — filters as you type
- Starter
- Pro
- Business
- Enterprise — contact sales
select-only — a div, behaving like <select size="1">
- Starter
- Pro
- Business
Click or press ↓ to open either one. Focus never leaves the input.
<div class="n-combobox">
<input class="n-input" role="combobox" aria-autocomplete="list">
<ul class="n-listbox" role="listbox" aria-label="Plans">
<li class="n-option" role="option">Starter</li>
<li class="n-option" role="option" aria-selected="true">Pro</li>
<li class="n-option" role="option" aria-disabled="true">Enterprise</li>
</ul>
<p class="n-combobox-status" role="status"></p>
</div>
Two states that are not the same thing, and conflating them is the commonest combobox bug there is:
| Means | Moves on | |
|---|---|---|
[data-current] |
where the arrow keys are — the visual twin of aria-activedescendant |
every keypress |
[aria-selected="true"] |
the value actually committed | Enter |
They get different weights, because during arrowing both are on screen at once and the user
has to tell "I am looking at this" from "I chose this". [data-current] gets an inset bar as
well as a background, because the gap between --neutral-3 and --neutral-4 is one contrast
step by construction and one step cannot carry "this is the cursor" alone. Selection gets a
✓, for the same 1.4.1 reason every status carries a glyph.
The listbox is a plain absolutely-positioned element, not a [popover], and that has a
cost worth naming: an ancestor with overflow: hidden will clip it. A popover would get the
top layer for free, but popover="auto" light-dismisses on the first outside pointer-down —
which for a combobox includes clicking back into its own input — and the whole focus design
is that DOM focus never leaves the input.
Slider¶
Also needs nilam/behaviours and nilam/widgets.css.
Use <input type="range"> unless you cannot
A native range input already has every key below, works with touch assistive technology,
submits with a form and needs no JavaScript. This exists for the two cases it cannot
cover: a value that must be spoken differently from how it is stored ("£1,200 per
month", "2 minutes 14 seconds" — that is aria-valuetext, and <input type="range"> has
no equivalent), and a track you need to decorate beyond what
::-webkit-slider-thumb will let you reach.
The APG attaches a standing warning to every slider example, and it is not theoretical: some users of touch-based assistive technology cannot operate this pattern, because the gestures their AT provides may not generate the necessary output. Nothing here fixes that.
horizontal
disabled, and vertical
<div class="n-slider">
<div class="n-slider-track"><div class="n-slider-fill"></div></div>
<div class="n-slider-thumb" role="slider" tabindex="0" aria-label="Volume"
aria-valuemin="0" aria-valuemax="100" aria-valuenow="40"></div>
</div>
<span class="n-slider-value">40</span>
The module writes one custom property, --n-slider-pct, on the wrapper, and both the fill
and the thumb read it. Nothing in the JavaScript sets inset-inline-start or a width — which
is what lets RTL and the vertical variant stay a CSS concern. The vertical variant is
selected by :has([aria-orientation="vertical"]), reading the same attribute the module
reads, so the two cannot disagree.
The thumb gets the same 44px ::after target trick as the button, and touch-action: none
on the wrapper — without which a drag on a touch screen scrolls the page instead of moving
the thumb.
Range¶
Also needs nilam/behaviours and nilam/widgets.css. Two thumbs, so there is no native
element at all: <input type="range"> has exactly one.
horizontal
disabled, and vertical
<div class="n-range">
<div class="n-range-track"><div class="n-range-sel"></div></div>
<div class="n-range-thumb" role="slider" tabindex="0" aria-label="Minimum price"
aria-valuemin="0" aria-valuemax="100" aria-valuenow="20"></div>
<div class="n-range-thumb" role="slider" tabindex="0" aria-label="Maximum price"
aria-valuemin="0" aria-valuemax="100" aria-valuenow="60"></div>
</div>
| Class | What it is |
|---|---|
.n-range |
the wrapper. Carries --n-range-from and --n-range-to, and touch-action: none |
.n-range-track |
the full extent, --void fill inside a --neutral-7 border |
.n-range-sel |
the selected span between the thumbs, --brand-9 |
.n-range-thumb |
one per end. range.mjs stamps data-n-range="from" / "to" on them |
DOM order is the contract: the first thumb is the lower one. The module writes
data-n-range from that order, and the CSS positions each thumb off the attribute, so the
stylesheet cannot end up disagreeing with the module about which thumb is which. Vertical
is :has([aria-orientation="vertical"]), reading the same attribute the module reads.
The track's border is not decoration. A range's fill sits in the middle of its track,
so without a boundary both remainders vanish and a user can see the span they picked but
neither end it could reach. Measured on a card: --neutral-4 is 1.0844:1 in dark mode and
--neutral-3 is 1.0009:1, against the 3:1 WCAG 1.4.11 asks of a control's extent.
--neutral-7 is 3.0469:1 in dark and 3.6537:1 in light. The fill is --void; the border is
what carries the ratio. .n-progress, .n-bar and .n-meter all work the same way.
The two 44px ::after targets overlap when the range closes to nothing. That is fine:
pointerdown picks the nearer thumb by value, not by hit target, and on an exact tie it
picks the one the clamp still lets move — otherwise a press-and-drag on two piled-up thumbs
would do nothing at all.
See Behaviours for the keys, the clamping rule and why each thumb's
aria-valuemin/aria-valuemax move as the other thumb moves.
Links¶
Body text with a link that carries the hue in it.
Step 11 for the text — the 4.5:1 role, because a link is text — and step 8 for the
underline, so the line is present but quieter than the word. On hover the underline goes to
currentColor.
The underline stays. Colour is an addition to the affordance, never a replacement for it (WCAG 1.4.1), and it is what keeps the link findable for anyone who cannot separate violet from ink.
Layout¶
<div class="n-container">
<div class="n-stack">…</div>
<div class="n-cluster">…</div>
<hr class="n-divider">
</div>
| Class | What it does |
|---|---|
.n-container |
centred, max-inline-size: 72rem, --space-4 gutters |
.n-stack |
vertical flex column, --space-4 gap |
.n-cluster |
horizontal wrap-and-gap row, --space-2 gap |
.n-divider |
a step-6 hairline, no margin of its own |
stack
cluster
Prose¶
For CMS and markdown output where you do not control the markup and cannot add a class per element. It is the one place headings get sized, because everywhere else the author decides.
The measure is --measure, which is 65ch, not a rem width. What matters for readability
is characters per line, and ch tracks the font, so it stays inside the readable range at
every --text-* size. A fixed rem width does not.
blockquote gets a step-8 brand rail: the one flash of hue in a wall of text, at exactly the
place a reader's eye should be pulled.
Keyboard hints¶
Press I to set the in point, O for out, and CtrlS to export.
nilam.base.css already gives code, kbd, samp, pre the mono face, and that is all it
gives them — so <kbd>I</kbd> and <code>I</code> rendered identically for five releases,
including on this site's own Behaviours page. "Press I" and "the value I"
are different claims and they should not look the same.
Use the class, not the bare element. Styling kbd directly would be nilam deciding how your
prose looks; a class is opt-in.
The border is not decoration. --neutral-3 is 1.0009:1 on a card in dark mode, so a
background alone would have shipped the very non-distinction this exists to fix. The
--neutral-7 edge at 3.0469:1 is what makes it read as a key, and the heavier bottom edge is
the key travel — one border-width rather than a blurred shadow, so it survives
forced-colors and printing.
Write a combination as separate elements; .n-kbd + .n-kbd handles the gap.
Numbers¶
.n-num is font-variant-numeric: tabular-nums and nothing else. No alignment and no
colour — which is the whole reason it exists separately from .n-table-num,
which also sets text-align: end, white-space: nowrap and --neutral-12 because inside a
table all four belong together. Reaching for .n-table-num on a standalone figure imported
two properties you did not ask for.
Why a class rather than Tailwind's tabular-nums at each call site: a house rule that every
number which updates gets tabular figures is only enforceable if it has a name. A counter
ticking 9 → 10 reflows the line without it.
Two utilities that are load-bearing¶
<a class="n-skip" href="#main">Skip to content</a>
<span class="n-sr-only">, opens in a new tab</span>
.n-sr-only is the only correct visually-hidden. display: none removes the element from
the accessibility tree entirely, and text-indent: -9999px breaks RTL and gets announced as
a huge empty box. This one uses clip-path: inset(50%) on a 1px box.
.n-skip is hidden by translate: 0 -200% until :focus-visible, then it is a real target
painted in step 9 with its ink. WCAG 2.4.1, and it costs one line.
Read next¶
- Behaviours — the keyboard layer for tabs, menu, combobox, slider and range.
- Tokens — what every step in those class names actually promises.
- Limitations — including the fact that none of this has met a screen reader.