Intrinsic CSS layout primitives, branding, and styling with Sindarin names and custom elements.
npm install elvish-cssAn implementation of a design system with custom elements, with Elvish names from Tolkien's Sindarin language. Extended with modern CSS features including @function, if(), sibling-index(), and typed attr().
Mae govannen! (Well met!)
Elvish is built on three principles:
1. Intrinsic design - Layouts that respond to their content and context, not arbitrary breakpoints
2. Composition over inheritance - Simple primitives that combine to create complex layouts
3. Algorithmic CSS - Let the browser calculate optimal layouts
All layout primitives use Sindarin (Grey-Elvish) names:
| Sindarin | English | Meaning |
|----------|---------|---------|
| | Stacked | "row, series" |
| | Quad | "container" |
| | Centered | "middle" |
| | Clustered | "small sparks" |
| | Sidebar | "clear + mighty" |
| | Switching | "change-watcher" |
| | Covering | "screen, hiding" |
| | Grid | "jewel-pattern" |
| | Aspect | "harp-foot (ratio)" |
| | Side-Scrolling | "open + hollow" |
| | Overcast | "white phantom" |
| | Icon | "sign, token" |
| | Container | "liberator" |
| | Sticky | "steadfast" |
| | Grid-placed | "jewel-work" |
| | Masonry | "stone collection" |
Special attributes for (Icon):
- echuiol = "awakening" (active state)
- dhoren = "hidden" (visually hidden)
``html
`
`bash`
npm install elvish-css
`javascript`
// ES Modules
import 'elvish-css/dist/elvish.css';
import { transition, transitionTheme } from 'elvish-css';
Download the dist/ folder and serve from your own CDN or Ignition gateway. See DEPLOYMENT.md for detailed instructions including Ignition Perspective integration.
`html`
Elvish includes support for cutting-edge CSS features. Browser support as of January 2026:
| Feature | Chrome | Edge | Safari | Firefox |
|---------|--------|------|--------|---------|
| light-dark() | ✅ 123+ | ✅ | ✅ 17.5+ | ✅ 120+ |@function
| Relative Colors | ✅ 119+ | ✅ | ✅ 16.4+ | ✅ 128+ |
| | ✅ 139+ | ✅ 139+ | ❌ | ❌ |if()
| | ✅ 137+ | ✅ 137+ | ❌ | ❌ |sibling-index()
| | ✅ 138+ | ✅ 138+ | ❌ | ❌ |attr()
| Typed | ✅ 133+ | ✅ 133+ | ⚠️ | ❌ |
| View Transitions | ✅ 111+ | ✅ 111+ | ✅ 18+ | ✅ 144+ |
Single declarations that respond to color scheme:
`css`
:root {
color-scheme: light dark;
--color-surface: light-dark(#ffffff, #1a1a2e);
--color-text: light-dark(#1a1a2e, #f0f0f5);
}
Define one brand color, derive entire palettes:
`css`
:root {
--brand: oklch(55% 0.18 250);
/ Lighten/darken /
--brand-light: oklch(from var(--brand) calc(l + 0.15) c h);
--brand-dark: oklch(from var(--brand) calc(l - 0.1) c h);
/ Hue shift for complementary /
--brand-complement: oklch(from var(--brand) l c calc(h + 180));
/ Transparency /
--brand-ghost: oklch(from var(--brand) l c h / 0.1);
}
Define custom functions for reusable calculations:
`css
@function --neg(--value) {
result: calc(-1 * var(--value));
}
@function --alpha(--color, --opacity) {
result: oklch(from var(--color) l c h / var(--opacity));
}
.element {
margin-left: --neg(20px); / -20px /
background: --alpha(var(--brand), 0.5); / 50% opacity /
}
`
Conditional values without class toggling:
`css
.card {
--variant: default;
padding: if(
style(--variant: compact): var(--s-1);
style(--variant: spacious): var(--s2);
else: var(--s1)
);
}
.grid {
grid-template-columns: if(
media(width >= 900px): repeat(4, 1fr);
media(width >= 600px): repeat(2, 1fr);
else: 1fr
);
}
`
Elements know their position. No JavaScript needed for staggers:
`css
.stagger > * {
animation-delay: calc((sibling-index() - 1) * 100ms);
}
.rainbow > * {
--hue: calc(sibling-index() * (360 / sibling-count()));
background: oklch(70% 0.15 var(--hue));
}
`
Use HTML attributes as typed CSS values:
`html`Grid with 4 columnsBlue textProgress bar
`css
[data-columns] {
--columns: attr(data-columns type(
grid-template-columns: repeat(var(--columns), 1fr);
}
[data-color] {
color: attr(data-color type(
}
[data-progress] {
--progress: attr(data-progress type(
background: linear-gradient(to right, accent var(--progress), gray var(--progress));
}
`
Baseline Newly Available since October 2025 (Firefox 144). Smooth, animated transitions between layout states without JavaScript animation libraries:
`css
/ Enable in transitions.css /
@view-transition {
navigation: auto;
}
/ Named elements animate independently /
.sidebar { view-transition-name: glan-veleg; }
.grid { view-transition-name: vircantie; }
/ Auto-naming for lists/grids - each item gets unique name /
.card-grid > * {
view-transition-name: match-element;
view-transition-class: card; / Group styling /
}
/ Style all cards at once /
::view-transition-group(.card) {
animation-duration: 300ms;
animation-timing-function: var(--transition-ease-spring);
}
/ Customize specific animations /
::view-transition-old(glan-veleg) {
animation: 300ms ease-out slide-out-left;
}
::view-transition-new(glan-veleg) {
animation: 300ms ease-out slide-in-left;
}
`
`javascript
// Wrap DOM changes in a transition
import { transition, transitionTheme, transitionRatio } from './global/transitions.js';
// Basic usage
transition(() => {
sidebar.classList.toggle('collapsed');
});
// With typed transitions for CSS targeting
transition(() => {
grid.setAttribute('columns', '4');
}, { types: ['layout'] });
// Built-in Elvish helpers
transitionTheme('dark'); // Smooth theme switch
transitionRatio('golden'); // Smooth ratio change
// Auto-naming for grid items
import { enableAutoNaming } from './global/transitions.js';
enableAutoNaming(gridElement, 'card'); // Each child animates independently
`
Included transition animations: fade, slide (up/down/left/right), scale, flip
New features: match-element auto-naming, view-transition-class group styling, :active-view-transition pseudo-class
Fallback: Browsers without View Transitions get CSS transition fallbacks on common properties.
| Sindarin | English | Key Props |
|----------|---------|-----------|
| | Stacked | space, recursive, split-after |
| | Quad | padding, border-width, invert |
| | Centered | max, gutters, intrinsic |
| | Clustered | space, justify, align |
| | Sidebar | side, side-width, content-min |
| | Switching | threshold, space, limit |
| | Covering | centered, space, min-height |
| | Grid | min, space |
| | Ascpect | ratio |
| | Side-Scrolling | item-width, space, no-bar |
| | Overcast | fixed, contain, margin |
| | Icon | space, label, echuiol, dhoren |
| | Container | name |
| | Sticky | to, offset, sentinel |
| | Grid-placed | columns, space, dense |
| | Masonry | columns, space |
` Paragraphhtml`
Title
`html`
Card 1
Card 2
Card 3
`html`
`html`
Elvish uses a modular scale for harmonious spacing and typography. Choose the ratio that fits your design:
| Ratio | Value | Character | Best For |
|-------|-------|-----------|----------|
| Golden (φ) | 1.618 | Dramatic, organic | Marketing, CTAs, hero sections |
| Silver (√2) | 1.414 | Subtle, refined | Documentation, dashboards, dense content |
| Fifth | 1.5 | Balanced, musical | General purpose |
Default: Perfect Fifth (1.5) for balanced harmony.
`css
/ Switch globally /
:root {
--ratio: var(--ratio-golden); / For dramatic impact /
}
/ Switch per-section /
.marketing-hero {
--ratio: var(--ratio-golden);
}
.documentation {
--ratio: var(--ratio-silver);
}
/ Use utility classes /
/ Or data attributes (for JS toggling) /
Scale values at 16px base:
`
Golden (φ) Silver (√2) Fifth (1.5)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
s-2 6px 8px 7px
s-1 10px 11px 11px
s0 16px 16px 16px ← base
s1 26px 23px 24px
s2 42px 32px 36px
s3 68px 45px 54px
s4 110px 64px 81px
s5 178px 91px 122px
`$3
`css
:root {
/ Ratio presets /
--ratio-golden: 1.618;
--ratio-silver: 1.414;
--ratio-fifth: 1.5;
--ratio: var(--ratio-fifth); / Active ratio (default: Fifth) /
/ Modular scale /
--s-2, --s-1, --s0, --s1, --s2, --s3, --s4, --s5
/* MEASURE vs LAYOUT THRESHOLDS
* --measure: For TEXT readability (ch units)
--layout-threshold-: For LAYOUT decisions (rem units)
*
* Text is about characters; layout is about physical space.
*/
--measure: 70ch; / Golden=60ch, Fifth=70ch, Silver=80ch /
/ Layout thresholds /
--layout-threshold-sm: 30rem; / ~480px - phone landscape /
--layout-threshold-md: 45rem; / ~720px - tablet portrait /
--layout-threshold-lg: 60rem; / ~960px - tablet landscape /
--layout-threshold-xl: 75rem; / ~1200px - small desktop /
/ Brand + derived colors (relative color syntax) /
--brand, --brand-light, --brand-dark, --brand-complement
/ Semantic (auto light/dark) /
--color-surface, --color-text, --color-accent, --color-border
/ Timing /
--duration-fast, --duration-normal, --duration-slow
/ Easing /
--ease-out, --ease-in, --ease-bounce
}
`$3
Elvish distinguishes between two types of "widths":
| Token | Purpose | Unit | Example |
|-------|---------|------|---------|
|
--measure | Text readability | ch | max-inline-size: var(--measure) |
| --layout-threshold-* | Layout switching | rem | |Why the distinction?
- Text measure is about characters per line for readability (45-75ch optimal)
- Layout thresholds are about physical space for comfortable item widths
- A 70ch measure at 16px ≈ 560px—too narrow for layout decisions!
`html
Readable prose...
Horizontal until 720px
Then vertical
`File Structure
`
elvish/
├── global/
│ ├── tokens.css # Design tokens, light-dark(), relative colors
│ ├── reset.css # Reset + measure axiom
│ ├── utilities.css # Utility classes
│ ├── modern.css # @function, if(), sibling-index(), attr()
│ ├── transitions.css # View Transitions API
│ ├── transitions.js # View Transitions helpers
│ └── global.css # Imports all CSS
├── primitives/ # Sindarin-named layout primitives
│ ├── hath/ # Stacked
│ ├── bau/ # Quad
│ ├── enedh/ # Centered
│ ├── tiniath/ # Clustered
│ ├── glan-veleg/ # Sidebar
│ ├── gwistindor/ # Switching
│ ├── esgal/ # Covering
│ ├── vircantie/ # Grid
│ ├── gant-thala/ # Aspect (ratio)
│ ├── glan-tholl/ # Side-Scrolling
│ ├── fano/ # Overcast
│ ├── thann/ # Icon
│ ├── adleithian/ # Container
│ ├── him/ # Sticky
│ ├── miriant/ # Grid-placed
│ └── gonath/ # Masonry
├── examples/
│ └── complete-demo.html
├── elvish.css # All primitive styles
├── elvish.js # All primitive JS + SINDARIN vocabulary
└── README.md
`Progressive Enhancement
Elvish includes fallbacks for browsers without modern feature support:
`css
/ Fallback for sibling-index() /
@supports not (animation-delay: calc(sibling-index() * 1ms)) {
.stagger-enter > :nth-child(1) { --i: 0; }
.stagger-enter > :nth-child(2) { --i: 1; }
/ ... /
}/ Fallback for if() /
@supports not (color: if(style(--x: y): red; else: blue)) {
.theme-aware { background: var(--fallback); }
.theme-aware.is-dark { background: var(--dark-fallback); }
}
``