Design guidelines
Build a coherent interface with ranui: choose roles and states, then let tokens supply the values.
Use the design system for tokens, theming for runtime overrides, and information architecture for page structure. This page summarizes the canonical DESIGN.md.
The interface index
routes behavior to INTERACTION,
engineering to CODING, and relevant host tasks to
optional workflow patterns.
These references also ship under node_modules/ranui/docs/.
Use when choosing color, spacing, type, depth or motion for a screen built with
<r-*>elements.
Principles
- Clarity before personality. The primary task and the primary action must be unmistakable before anything else is considered.
- Reuse existing controls. Prefer semantic native controls or supported ranui primitives
such as
r-button,r-input,r-selectandr-modalwhen their contracts fit. Preserve their form, keyboard and accessibility behavior rather than rebuilding it for styling. - Tokens, never raw values. A hex code, a
20pxgap or a hand-picked shadow is a decision that will not follow the theme. - Decide by role and state, not by eye. "What is this text?" (heading / label / copy / button) has an answer; "what size looks right?" does not.
- Design every reachable state. Default, hover, active, focus, disabled, loading, empty, error: the default state is one of eight.
- Verify what rendered. In light and dark, narrow and wide, mouse and touch. Review does not catch a shadow you cannot see.
Conflict order when two rules pull apart: user goals → verified evidence → these guidelines → shipped patterns → general heuristics.
Choosing a color
Color is assigned by role and state, never picked by eye. The ladder already fixes what hover and active look like; your job is to name the role.
| The element is… | Use |
|---|---|
| A page or surface background | --ran-color-bg / -bg-subtle / -bg-elevated / -bg-muted |
| Under the pointer / being pressed | --ran-color-bg-hover / -bg-active |
| Text | --ran-color-text / -text-secondary / -text-disabled |
| A border | --ran-color-border / -hover / -active |
| The one action the screen is for | --ran-color-primary (+ --ran-color-primary-text on it) |
| A status | --ran-color-success / -warning / -danger |
| A link | --ran-color-link |
Accents have one meaning each. Primary is monochrome (black-on-white in light, white-on-black in dark), so don't use blue for it: blue belongs to links and the focus ring. Green is success, amber is warning, red is danger; using red for emphasis leaves you with no way to use it for danger later.
Three rules that prevent silent breakage:
- Never hard-code a hex or
rgb()for a value that should follow the theme. - A fallback must name a token that flips:
var(--ran-color-text, var(--ran-gray-1000)), nevervar(--ran-color-text, #171717): a light-only literal disappears in dark mode. - A fallback must name a token that exists.
var()on an undeclared property resolves to nothing, the whole declaration is dropped, and the element keeps whatever it inherited, which usually looks almost right. (--ran-color-errordoes not exist; it is--ran-color-danger.)
Spacing and rhythm
Take every gap from the nine-value scale, and let the distance carry meaning:
- 8px between elements inside a group.
- 16px between groups.
- 32–40px between sections.
Don't invent 20px or 28px. The limited set is what produces the page's rhythm; one
off-scale gap is what breaks it. Keep shared spines across regions (edges, baselines and
columns that line up) and verify alignment against the rendered pixels rather than by eye.
Choosing type
Ask what role the text plays (heading, label, copy, button, mono) and the font, size, weight and line-height all follow from the type scale. Don't pick raw px per instance.
A role is a tool, not a law: genuinely one-off decorative text (a gesture-flash overlay, an active-link weight bump) is better off with its own component token than forced into the nearest role.
Depth: shadow and stacking
Pick the shadow tier by what the element is (in-flow surface, floating overlay, or blocking dialog) and make sure it is actually perceptible. A shadow that cannot be seen provides no depth cue, and an overlay that falls back to the card tier looks pinned to the page.
Embedding ranui overlays in your own chrome. The z-index
ladder starts at 1000 precisely so it clears ordinary page
chrome. A portaled overlay therefore needs no help from you. But a position: fixed overlay
that stays inside its own shadow DOM (r-modal's dialog) only escapes as far as its nearest
ancestor stacking context, so if you wrap embedded content in anything that creates one
(isolation, opacity < 1, transform, filter, will-change), that wrapper's stacking
level has to be raised so the dialog is stacked above it again. Scope the escalation to when
an overlay is actually open:
.embed {
isolation: isolate; /* cheap: no z-index of its own, so nothing is promoted */
}
/* Escalate only while a real overlay is open — never "just in case" */
.embed:has(r-modal[open]),
.embed:has(r-modal[closing]) {
position: relative;
z-index: 100;
}A blanket z-index on the wrapper elevates everything inside it (including entirely static
content) over your sticky header for its whole scroll lifetime. That bug shipped on this very
site once. Match closing as well as open: the mask keeps painting for the length of its
transition after open is removed.
Motion
Purpose and frequency decide whether to animate. Frequent hover, focus and keyboard feedback
is immediate by default. Use existing motion tokens only when motion explains a change: a small
transition may take ~150ms, menus ~200ms and dialogs ~300ms. These are optional examples, not
required delays. Keep motion interruptible and respect prefers-reduced-motion.
Never let a palette property transition. CSS cannot tell why a color changed, so a
transition on background-color, color, border-color, box-shadow, fill or stroke
also fires when the theme flips, with every element fading at its own pace while the rest
of the page has already switched. Animate motion properties (transform, opacity, geometry)
instead. transition: all and bare shorthands like transition: 0.2s mean all, palette
properties included; both are banned in ranui's own styles and are a bad idea in yours.
States and content
Every reachable state is part of the design: hover, active, focus, disabled, loading, empty,
error. Map them onto the ladder: hover → bg-hover / border-hover; active → bg-active;
disabled → disabled tokens; reduce opacity only when needed and still legible; focus → the focus ring.
Nothing non-interactive may look interactive. r-card only reacts to hover with the
hoverable attribute; leave it off for cards that don't click.
Copy is part of the system too:
- Buttons name the outcome. Include the object when context does not establish it: "Delete member". A clearly named dialog may use "Delete"; avoid generic "OK".
- Errors say what happened, then how to fix it. ✅ "Build failed: the bundle exceeds the size limit. Reduce it or raise the limit." ❌ "Operation failed, please try again."
- Confirmations and toasts state the change, not the success. ✅ "Project deleted" ❌ "Operation successful". Show completion feedback only after the operation is confirmed.
- Let context remove redundancy: a dialog titled "Delete project" does not need a button labelled "Delete project permanently, forever".
Accessibility
- Meet WCAG AA contrast for text against its background.
- Never signal state with color alone: pair it with an icon, a label or text.
- Every interactive element keeps a visible focus ring (
--ran-focus-ring, oroutline: 2px solid var(--ran-color-primary); outline-offset: 2px). Never remove it for tidiness. - Everything is reachable by keyboard. Nothing is mouse-only.
- Respect
prefers-reduced-motionandprefers-color-scheme.
Mouse and touch, narrow and wide
Neither input nor viewport is the secondary target.
- Drag, slider and gesture interactions use Pointer Events (
pointerdown/pointermove/pointerup/pointercancel), nevermouse*alone, paired withtouch-action: noneon the exact drag surface. CSS declaringtouch-action: nonewith no pointer handler behind it is a broken control, not a harmless no-op. - A hover-only affordance needs a tap fallback.
trigger="hover"onr-selectorr-popoverdegrades to click on touch devices; anything you build must do the same. - Prefer viewport-relative sizing (
%,min(),max(),clamp(),vw/vh, e.g.min(560px, calc(100vw - 32px))) over inventing a breakpoint. ranui has no shared breakpoint token, so every hard breakpoint is a one-off number someone has to maintain. - Never hide the only way to do something on mobile. Reflow instead of
display: none. - A measured position is only correct until the next reflow. Anything derived from
getBoundingClientRect()goes stale on resize, on container reflow, and (for a portaled panel) on scroll. Re-measure on those events, not only on the interaction that first triggered the measurement. Loading a page at a narrow width exercises the initial layout; it does not exercise resizing into it, which is where this bug class actually appears.
What the library enforces mechanically
Nine of these rules are checked by pnpm -F ranui verify:design, which CI runs on ranui's own
source: dark-unsafe color fallbacks, raw color literals, the spacing scale, the sizing scale,
mouse-only drag loops, :host display rules that break hidden, fallbacks naming undeclared
tokens, components querying their own shadow tree, and shadow trees built outside the
constructor. Known violations are ratcheted in a baseline file, so a new one cannot be added
and a fix cannot be silently undone.
These checks scan only the source roots configured for the command; they do not automatically inspect applications that use ranui. This repository has separate checks for surfaces such as the documentation site. Application developers use their own checks and verify layout, themes and interaction in the browser.
Design examples
Schematics derived from ranui foundation tokens, not live components.
Keep rows aligned
When a row gains extra content, keep its text aligned with other rows and its actions in the same column.
Coordinate nested corners
When both inner and outer boundaries are needed, subtract the inset from the outer radius: 16 − 4 = 12. The curves then share a center and look evenly spaced.
--ran-radius-lg − --ran-space-1 = --ran-radius-mdMake small icons easier to click
The 16px icon sits inside a 32px click target, making it easier to use. Choose the target size for mouse or touch input, keeping neighboring targets separate.
--ran-size-1 · --ran-size-7Checklist before shipping UI
Choose checks for the affected surface and changed behavior; repository CI still runs its complete gates.
- Primary task and primary action are unmistakable.
- Works in light and dark, at narrow and wide widths.
- Works with mouse and touch; any hover trigger has a tap fallback.
- Affected states exercised: hover, active, focus, disabled, loading, empty, error.
- Keyboard and focus verified; focus visible everywhere.
- Edge cases: long text, large numbers, supported locales.
- Spacing from the scale, type by role, color from semantic tokens.
- No palette property in a
transition; notransition: all. - Copy makes the action and its object clear from context; nothing signals state by color alone.