Skip to content

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

variants

sizes, icon-only, and an anchor

An anchor

loading — aria-busy, on every variant

<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

Generating

progress

62%

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

Pro Active !Expiring ×Failed iTrial Neutral
<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

Payment received. Your next invoice is on 1 September.
i
Info carries no hue at all. The state that means nothing is wrong does not need one — and spending a hue on it collided with the brand.
!
Card expires next month. Update it before 1 September.
×
Last charge failed. The bank declined the transaction.
·
Neutral. No variant class.
<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

AccountOwnerStatusMRR
NorthwindNWActive£4,200
ContosoCO!Expiring£2,750
InitechIN×Failed£1,980
UmbrellaUMiTrial£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

SM MD LG
<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

Delete workspace

This removes every project and cannot be undone. Type the workspace name to confirm.

the same markup, pinned into the page so it is visible here

Delete workspace

This removes every project and cannot be undone.

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

Positioned by CSS, not by JavaScript.

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?
Because the brand took that region of hue space. With a violet brand at 285, the best blue available was 0.052 from it under deuteranopia — the same colour to that reader. Info means "nothing is wrong", so it does not need to shout.
Why is step 9 different in each mode?
A filled button inverts the polarity of its page. Dark object with light text on a light page; light object with dark text on a dark one.
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.

<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">

Pro
  • 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

40

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

20–60

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.

Body text with a link that carries the hue in it.

<a class="n-link" href="/somewhere">a link that carries the hue</a>

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

one
two

cluster

alphabetagamma deltaepsilon

Prose

<article class="n-prose">…rendered markdown…</article>

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.

Press <span class="n-kbd">I</span> to set the in point.

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

<span class="n-num">1,284</span> requests · <span class="n-num">99.98%</span> uptime

.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.

  • 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.