@johnfilipstad/yme
v0.3.0
Published
Framework-agnostic CSS design system: cascade layers, runtime OKLCH token engine, semantic HTML contracts.
Maintainers
Readme
Yme
Yme is a standalone, framework-agnostic CSS design system: pure modern CSS in cascade layers, a runtime OKLCH token engine, semantic HTML contracts, and two tiny optional JS modules. The name comes from Norse mythology: Yme, the Norwegian form of Ymir, the primordial giant whose body became the raw material for all worlds. Everything is built from Yme.
Most design systems make every site look like the framework. Yme derives the entire visual identity at runtime from a handful of input tokens: one small override file retints, rescales, and reshapes everything, so two sites can share every line of CSS and no visual identity.
- One token seed, whole-system change. Brand color, type ratio, spacing, radius: override the inputs and every derived value follows, per page or per subtree.
- Class-less base. Bare semantic HTML looks finished before you write a single class.
- Markup contracts, not framework components. Plain HTML plus CSS, documented per component. Works with Astro, anything else, or no framework at all.
- Layers as the extension model. Your unlayered CSS wins over all of Yme without specificity games. The one exception is deliberate: reduced motion cannot be overridden.
- Accessible by derivation. AA contrast pairs, dark mode, visible focus, and reduced motion come out of the engine, not out of remembering.
Install and import
npm install @johnfilipstad/ymeEverything at once (the bundle):
@import '@johnfilipstad/yme';Or piecemeal. Import layers.css first; it declares the layer order once, and every other file only adds rules into those layers, so their order never matters after it:
@import '@johnfilipstad/yme/layers.css';
@import '@johnfilipstad/yme/reset.css';
@import '@johnfilipstad/yme/tokens.css';
@import '@johnfilipstad/yme/base.css';
@import '@johnfilipstad/yme/layout.css';
@import '@johnfilipstad/yme/components/section.css';
@import '@johnfilipstad/yme/components/button.css';
/* ...only what you use */The JS modules (optional, enhancement only):
<script type="module" src="/node_modules/@johnfilipstad/yme/src/js/nav.js"></script>Or, if a bundler resolves your scripts, import '@johnfilipstad/yme/js/nav.js'; from your JS entry. Same for chat.
A note on paths: the bare @johnfilipstad/yme/... specifiers above are resolved by bundlers through the package's exports map. Anything referenced by literal URL (script tags, plain @import url()) must use the real file paths instead: dist/yme.css for the bundle, src/... for individual layers, components, and the JS modules, src/icons/... for icon files.
Consuming from a checkout or link
Registry installs and git installs both arrive with a ready dist/yme.css (git installs build it automatically via the prepare script; the build is zero-dependency). Only a bare checkout consumed over file: or pnpm link without ever running npm scripts lacks it: run npm run build there once, or import the individual src/ files layers-first as above.
Browser support
Yme requires evergreen browsers from roughly mid-2024: Chrome/Edge 123+, Firefox 128+, Safari 17.5+. The floor is set by light-dark() and OKLCH relative colors, which the token engine is built on: below it the tokens themselves do not resolve, so there is no graceful degradation path and no fallback build.
Three details are progressive enhancements above that floor, degrading gracefully where unsupported: exclusive-open FAQ groups via the details name attribute (needs Firefox 130; older Firefox opens items independently), text-wrap: pretty on leads and prose, and hyphens: auto, which depends on the platform's hyphenation dictionary for the page's lang and otherwise falls back to plain break-anywhere wrapping.
The layers
@layer yme.reset, yme.tokens, yme.base, yme.layout, yme.components, yme.utilities;Every Yme rule lives inside one of these layers. Nothing is unlayered. That single fact is the extension model:
yme.resetzero-specificity modern reset, plus the reduced-motion safety net (the system's only!important; important declarations in the earliest layer beat every later layer AND unlayered CSS, so reduced motion is a guarantee).yme.tokensthe three-tier token engine (see below).yme.baseclass-less semantic HTML: type hierarchy, vertical rhythm, links, lists, quotes, tables, code, forms, buttons, focus. All:where(), zero specificity.yme.layoutpage scaffold, skip link, containers, composition grid, band surface classes.yme.componentsone file per component, each with a markup contract comment at the top.yme.utilitiesnear-empty by design; a utility enters only when a real need appears.
The skin escape hatch
Per-site CSS goes in a skin stylesheet loaded after Yme, kept unlayered. Unlayered CSS wins over ALL layered CSS at equal importance, so any skin rule beats any Yme rule without specificity games. Inside Yme, base sits at zero specificity so components win; your skin sits outside the layers so it wins over everything. The one thing a skin cannot override is the reduced-motion safety net, and that is the point.
The repository's test pages include two theme demos built exactly this way: Fjellro (serif headings, sharp corners, airy pine green) and Skjold (tight augmented-fourth scale, pill-round, electric violet). Each is a token-override-only skin over the same components, and the two share no visual identity.
Token override points
Tier 1 inputs are declared on html; everything else derives from them at runtime. Override inputs, never derived outputs. Re-declare an input on ANY wrapper and the whole subtree re-derives, with two documented exceptions: --yme-font-body and --yme-leading are consumed once on body, so a wrapper overriding them must also re-anchor font-family and line-height on itself (see tokens.css).
| Token | Default | What it does |
| --- | --- | --- |
| --yme-color-brand | Nordic blue | The one color that tints everything: surfaces, text, borders, links, focus |
| --yme-color-secondary, --yme-color-accent | slate, warm amber | Independent supporting hues; each derives its own full ramp, ink, and on-color pair |
| --yme-color-success/warning/danger/info | AA-safe defaults | Status palettes |
| --yme-ratio | 1.333 | Type scale ratio. Presets: 1.2, 1.25, 1.333, 1.414, 1.618 |
| --yme-leading | 1.5 | Base line height; also sets --yme-rhythm (one line of base text) |
| --yme-font-body, --yme-font-heading | sans | Role fonts; point either at --yme-font-serif or --yme-font-mono to switch a role |
| --yme-space-scale | 1 | Multiplies the whole base-4 space scale (tight to loose) |
| --yme-radius-scale | 1 | 0 = sharp everywhere, 2+ = very rounded |
| --yme-container(-narrow/-wide) | 72/45/90rem | Container widths |
Color inputs must be opaque (any format); alpha-bearing colors turn every derived pair translucent and void the contrast guarantee, since the result depends on whatever sits underneath. Keep brand inputs roughly within OKLCH lightness 0.35 to 0.65; near-white or near-black inputs give the derivation engine too little room to build strong ramps. The on-color text pairs do not depend on that range: they flip on measured luminance and hold WCAG AA for any opaque input color, verified by an automated seed sweep across the rendered input space.
Theming: color-scheme: light dark follows the OS; data-theme="light|dark" on html or on a top-level wrapper overrides (a wrapper also re-anchors color and background-color, most simply by composing yme-band). yme-band--invert flips the scheme relative to the PAGE-level theme (html's data-theme or the OS). Out of contract by design: nesting data-theme wrappers inside each other, and data-theme combined with --invert on the same element; the invert resolution reads only the page-level theme and cannot see the nearest ancestor.
Page scaffold
<body class="yme-page">
<a class="yme-skip-link" href="#main">Hopp til innholdet</a>
<header class="yme-nav yme-band yme-band--invert">...</header>
<main id="main">
<section class="yme-section yme-band yme-band--muted" aria-labelledby="...">
<div class="yme-section__inner yme-section__inner--narrow">...</div>
</section>
</main>
<footer class="yme-footer yme-band yme-band--invert">...</footer>
</body>Surfaces come from composing yme-band classes (--muted, --brand, --invert); sections own spacing (--tight, --spacious, --flush). yme-section__inner establishes the container-query context all section-level components respond to; without one they keep their stacked mobile-first layout.
The JS modules
Two vanilla ES modules ship, both dependency-free and enhancement-only: js/nav.js (the mobile nav disclosure) and js/chat.js (the docked chat launcher and panel). Each owns its state attributes: aria-expanded is added by the module on init, never shipped in static markup, and the nav toggle and chat launcher ship with the hidden attribute until the module reveals them. That is by design: without JS the nav menu is simply always expanded, and the chat launcher never appears at all, since the chat cannot function without the consumer application's transport. Both are keyboard operable, and Escape closes and returns focus to the trigger. The docked chat panel additionally requires a close button in its header (data-yme-chat-close), which js/chat.js wires to the exact same close-and-refocus path: on the sub-30rem fullscreen takeover the launcher is covered and touch users have no Escape. Opening the docked chat moves focus into the panel (the close control first), never leaving it on a launcher the takeover covers. Below the 30rem breakpoint the open panel is a modal dialog: everything outside it goes inert (the covered launcher included, across wrappers and shadow boundaries), and the panel takes role="dialog", aria-modal="true", and an accessible name from its header title or the launcher's label. Modality tracks the breakpoint live while the panel is open, and every attribute the module generates is reverted on close; consumer-authored names, roles, and aria-modal values are preserved. The desktop corner panel stays non-modal by design: the page remains visible and usable beside it.
Markup contract index
The canonical contract for each component is the @yme-contract comment block at the top of its file under src/components/. Copy from there, not from memory. The repository also generates a single-page view of every contract from those same blocks (docs/contracts.html, regenerated with npm run contracts); the comments remain the source of truth, and the generator fails if this index and the contract blocks ever drift apart.
| Component | File | Variants |
| --- | --- | --- |
| Section | section.css | spacing: --tight, --spacious, --flush; surfaces via yme-band |
| Button | button.css | bare = primary, --secondary, --quiet |
| Hero | hero.css | --split, --full-bleed, --minimal |
| Nav | nav.css | utility row, language switcher pattern, icon or text toggle (menu/close swap on aria-expanded); JS: js/nav.js |
| Footer | footer.css | multi-column, --centered |
| Card | card.css | stretched title link, media slot, --yme-card-media-aspect token |
| Services | services.css | --cards, --list, --image-rows |
| Trust bar | trust-bar.css | quiet, --badges |
| Steps | steps.css | vertical timeline, --row |
| FAQ | faq.css | hairline, --boxed; native details, no JS |
| Reviews | reviews.css | inline, --cards |
| CTA band | cta-band.css | centered, --start |
| Form | form.css | states (error/success/hints), honeypot, Turnstile slot |
| Prose | prose.css | opt-in reading scope for rendered markdown |
| Icon | icon.css | 1em slot contract, --large; ships the sixteen-icon UI set |
| Chat | chat.css | --docked (launcher, header with title/subtitle and required close, corner panel), --inline; JS: js/chat.js |
The icon set
Sixteen original UI icons ship as plain SVG files under icons/, importable as @johnfilipstad/yme/icons/<name>.svg: chat, sun, moon, menu, close, chevron-down, chevron-right, arrow-right, phone, mail, check, x-mark, external-link, map-pin, clock, search. One discipline across all: 24-unit viewBox, 2-unit stroke, round caps and joins, stroke="currentColor" fill="none", 1-unit safety padding. close is the dismiss cross; x-mark is the compact cross sized to pair with check. Use them per the yme-icon slot contract: paste the file content inline (or include it at build time) and add the class.
Social and brand icons are never designed originally (trademark and recognition): vendor the official marks or Simple Icons per site through the same slot. The set is deliberately fixed; additions arrive as minor versions.
Conventions
- Semantic HTML first; native elements before custom behavior (details for accordions).
- BEM with the
yme-prefix. No aliases, no duplicate ways to say the same thing. - All values from tokens. The one sanctioned exception: glyph-relative micro-geometry (underline offsets, inline-code padding, letter-spacing, icon sizing) in em.
- Logical properties only. Mobile-first. Viewport queries for page layout, container queries for components.
- WCAG AA: visible focus everywhere, contrast-safe pairs, keyboard operability, reduced motion respected.
- JS is enhancement only and owns its state attributes (see The JS modules).
- No em dashes in documentation, comments, or demo copy.
Versioning
0.x semantics, so the consumer can pin with known meaning:
- Minor (
0.2.0): any contract change. That includes markup contracts, class and variant names, public token names, export paths, JS module behavior, the browser-support floor, and additions of new components or icons (additive, but they extend the contract surface). - Patch (
0.1.1): CSS-internal fixes within existing contracts.
Pin with a tilde range (~0.1.0): patch releases flow automatically, minors get reviewed. Below 1.0, npm's caret ranges happen to behave the same way; the tilde just states the intent.
Sponsors
Yme is developed with the support of Ibenta.
