diff --git a/README.md b/README.md index ca1e785..f89a8e5 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,27 @@ answers how far that surface is from the canvas. They are independent. Applied as classes: `.nv-material-frosted`, `.nv-elevation-3`. A shadow that is on neither scale is a shadow that means nothing. +### Light + +Elevation is the shade a surface casts. **Glow** is the light it gives off — +and it is reserved for things that are *on*: a live status, the plan you are +on, a finished step, the selected item in a showcase. Glow never replaces an +elevation; a glowing surface still rests on something. + +| Where | How | +| ----- | --- | +| A card that belongs to something with a color | `.nv-card--lit` — a lit top edge in `--nv-card-tint` | +| The one card that is on | `.nv-card--glow` | +| A showcase card | `.nv-card--spotlight` — light follows the pointer | +| A feature icon or product mark | `.nv-tile`, `--glow` to light it | +| A person | `.nv-avatar--glow` | +| A display line | `.nv-text-lit` — type cut from the same lit material | + +Glows come in the same steps as everything else (`--nv-glow-sm/md/lg`), and a +tinted surface glows in its own tint, mixed down to `--nv-glow-strength`. Two +glowing things side by side cancel each other out: if everything is lit, +nothing is. + ## Continuity Every interaction should read as a continuation of the state before it. When @@ -182,6 +203,11 @@ works; changes simply arrive rather than travel. It also wires `[data-nv-theme]`, `[data-nv-dialog]`, `[data-nv-tabs]` and `[data-nv-close]` so the common cases need no code at all. +Long pages can arrive in the order they were written: `.nv-reveal` rises into +place once as it scrolls into view, and `.nv-reveal-group` brings its children +in one after another, `--nv-stagger` apart. Only the script marks anything as +waiting, so without it — and under reduced motion — nothing is ever hidden. + ### One rule worth knowing A `view-transition-name` must be unique among all rendered elements, or the @@ -391,7 +417,7 @@ probably not the problem the system has. | -------------- | --------------------------------------------------------- | | `.nv-button` | `--primary` `--subtle` `--ghost` `--danger` `--frosted` `--float`, `--sm`/`--lg`, `--icon`, `--pill`, `--block`, `.nv-button-group` | | `.nv-input` | with `.nv-field`, `.nv-input-group`, `.nv-select`, `.nv-checkbox`, `.nv-radio`, `.nv-switch`, `.nv-choice` | -| `.nv-card` | `--elevated` `--floating` `--inset` `--frosted` `--interactive`, `.nv-card-grid` | +| `.nv-card` | `--elevated` `--floating` `--inset` `--frosted` `--interactive`, light: `--lit` `--glow` `--spotlight`, `.nv-card-grid` | | `.nv-dialog` | native ``; `--sheet` `--compact` `--wide` | | `.nv-nav` | `--vertical` `--plain` `--inline`, plus `.nv-nav-tabs`, `.nv-tab-panel` and `.nv-breadcrumbs` | | `.nv-menu` | anchored popover with `.nv-menu-trigger`, `__item`, `__separator`, `__label`, `__shortcut` | @@ -400,6 +426,12 @@ probably not the problem the system has. | `.nv-bed` | ground that is visibly not ordinary background; `--quiet` `--vivid` `--single` `--flush` | | `.nv-badge` | state, not an action; `--accent` `--success` `--warning` `--danger`, `--solid` `--count` `--square` | | `.nv-table` | inside `.nv-table-frame`; `__number` `__actions`, `--compact` `--interactive` | +| `.nv-tile` | a small raised key holding one glyph; `--solid` `--glow`, `--sm` `--lg` `--xl` | +| `.nv-avatar` | round raised disc; `--ring` `--glow`, sizes, `__status`, `.nv-avatar-group` | +| `.nv-stat` | readings set into one recessed well with `.nv-stat-group`; `__label` `__value` `__delta` | +| `.nv-steps` | markers set into a rail; `data-state="done"` lights the way, `--vertical` | +| `.nv-deck` | one surface in front of others of its kind, with `__layer` | +| `.nv-skeleton` | a waiting well with a slow sweep of light; `--text` `--circle` | | `.nv-surface` | materials and elevation as utilities | Variants are defined by reassigning private `--_*` properties. Interaction — diff --git a/REFERENCE.md b/REFERENCE.md index de91346..3b8f139 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -76,6 +76,9 @@ States come from the element: `disabled`, `aria-disabled`, `aria-busy="true"` | `.nv-card--frosted` | glass card | Translucent, for use over imagery or a bed. | | `.nv-card--interactive` | clickable card | Lifts on hover, sinks on press. | | `.nv-card--flush` | edge-to-edge card | Removes part padding, for media or tables. | +| `.nv-card--lit` | accent-edge card, colored top border | A strip of light along the top edge in the card's tint, spilling a little down the face. For a card that belongs to something with a color. | +| `.nv-card--glow` | glowing card, highlighted card | Gives off its tint. For the one card that is *on* — the current plan, the live item. | +| `.nv-card--spotlight` | spotlight card, cursor glow | A soft light follows the pointer across the face (needs `niveus.js`). Showcases only. | | `.nv-card__header` | card header | | | `.nv-card__title` | card title | | | `.nv-card__subtitle` | card subtitle, supporting text | | @@ -86,6 +89,19 @@ States come from the element: `disabled`, `aria-disabled`, `aria-busy="true"` | `.nv-card__link` | stretched link | Expands one link's hit area over the whole card. | | `.nv-card-grid` | responsive card grid | Auto-fitting, equal-height columns. | +`--lit`, `--glow` and `--spotlight` combine with every elevation and with each +other. They take `--nv-card-tint` (the accent unless set on the card). + +## Deck + +| Class | Known as | Does | +| --- | --- | --- | +| `.nv-deck` | card stack, stacked cards | One surface in front of others of its kind. Every child shares one grid cell. | +| `.nv-deck__layer` | stack layer | A decorative card behind the front one, stepping back and dimming with depth. Up to two; mark them `aria-hidden="true"`. | + +Hidden `.nv-tab-panel`s can live in a deck together: the front surface is +whichever one is showing. + ## Dialog Built on native ``; open with `showModal()` or `nv.openDialog()`. @@ -173,6 +189,61 @@ Set `--nv-scroller-item` for item width, `--nv-scroller-fade` for the edge fade. | `.nv-badge--square` | square badge | | | `.nv-badge__dot` | status dot | | +## Tile + +| Class | Known as | Does | +| --- | --- | --- | +| `.nv-tile` | icon tile, icon badge, feature icon | A small raised key holding one glyph: tinted face, glyph in its tint. Not interactive. | +| `.nv-tile--solid` | filled icon tile | Saturated face with a light glyph. | +| `.nv-tile--glow` | glowing icon tile | Lit from inside. | +| `.nv-tile--sm` `.nv-tile--lg` `.nv-tile--xl` | tile sizes | 32 / 56 / 72px; default 44px. | + +`--nv-tile-tint` picks the color. An `` inside fills the key, an `` +takes half of it. + +## Avatar + +| Class | Known as | Does | +| --- | --- | --- | +| `.nv-avatar` | avatar, profile picture | Round raised disc holding an `` or initials. | +| `.nv-avatar--ring` | avatar ring, story ring | A ring in `--nv-avatar-tint`, set off by a gap of surface. | +| `.nv-avatar--glow` | glowing avatar | The ring, giving off its tint. | +| `.nv-avatar--sm` `.nv-avatar--lg` `.nv-avatar--xl` | avatar sizes | 28 / 64 / 96px; default 40px. | +| `.nv-avatar__status` | presence dot | Grey at rest. | +| `.nv-avatar__status--online` | online indicator | Lit green. | +| `.nv-avatar-group` | avatar stack, facepile | Overlapping row. | + +## Stat + +| Class | Known as | Does | +| --- | --- | --- | +| `.nv-stat-group` | stats row, KPI strip, readout | One recessed well holding several readings, split by hairlines. Wraps on its own. Use a `
`. | +| `.nv-stat` | stat, metric, KPI | One reading: label, value, optional delta, in that order. | +| `.nv-stat__label` | metric label | `
` inside a group. | +| `.nv-stat__value` | metric value, big number | `
`; tabular numerals. | +| `.nv-stat__delta` | change, trend | | +| `.nv-stat__delta--up` `.nv-stat__delta--down` | positive / negative trend | Success and danger text. | + +## Steps + +| Class | Known as | Does | +| --- | --- | --- | +| `.nv-steps` | stepper, timeline, process, progress steps | Markers set into a rail cut into the surface. Horizontal, vertical below 40rem. Use an `
    `. | +| `.nv-steps--vertical` | vertical stepper, timeline | Always vertical. | +| `.nv-steps__item` | step | `data-state="done"` lights it and its stretch of rail; `aria-current="step"` lights the marker only. | +| `.nv-steps__marker` | step number, step knob | | +| `.nv-steps__body` | step content | | +| `.nv-steps__title` | step title | | +| `.nv-steps__text` | step description | | + +## Skeleton + +| Class | Known as | Does | +| --- | --- | --- | +| `.nv-skeleton` | skeleton, loading placeholder, shimmer | A shallow well where content will be, with a slow band of light crossing it. Still under reduced motion. | +| `.nv-skeleton--text` | text line placeholder | The last of several lines ends short. | +| `.nv-skeleton--circle` | avatar placeholder | | + ## Table | Class | Known as | Does | @@ -256,6 +327,15 @@ var(--nv-sheen-raised)` over its role color. | `.nv-code` | code, monospace | 13px | | `.nv-text-primary` `.nv-text-secondary` `.nv-text-tertiary` | text color roles | Emphasis, not size. | | `.nv-text-accent` `.nv-text-success` `.nv-text-warning` `.nv-text-danger` | semantic text colors | | +| `.nv-text-lit` | gradient text, metallic text | Lit from above, settling into the accent. One display line per page. | +| `.nv-text-lit--accent` | accent gradient text | The accent alone, lit from above — for a phrase inside a display line. | + +## Reveal + +| Class | Known as | Does | +| --- | --- | --- | +| `.nv-reveal` | scroll reveal, animate on scroll | Rises and fades in once, as it scrolls into view. Needs `niveus.js`; without it, or under reduced motion, nothing is hidden. | +| `.nv-reveal-group` | staggered reveal | Its children arrive one after another, `--nv-stagger` apart. | ## Layout utilities @@ -309,6 +389,7 @@ material-frosted material-glass material-border window-close window-minimize window-zoom window-inactive window-control-ring window-glyph focus-ring focus-ring-contrast selection +glow ``` `on-*` is the text that goes on top of that color, and is derived — never set @@ -348,15 +429,17 @@ never reference them. | `--nv-sheen-` | `raised filled surface knob` — gradients laid over a role color as `background-image` | | `--nv-blur-` | `sm md lg xl` — also `backdrop-frosted` `backdrop-glass` `backdrop-scrim` | | `--nv-material-` | `{solid,elevated,inset,frosted,glass}-{bg,border,shadow}` | +| `--nv-glow-` | `sm md lg` (accent halos) · `blur-sm/md/lg` `spread-sm/md/lg` (geometry for a tinted glow) · `strength` (how much tint a glow carries) | ### Motion | Family | Steps | | --- | --- | -| `--nv-duration-` | `instant fast quick normal slow slower travel expand theme` | +| `--nv-duration-` | `instant fast quick normal slow slower travel expand theme shimmer` | | `--nv-ease-` | `standard entrance exit spring emphasized linear` | | `--nv-transition-` | `colors depth control surface expand` (composites) | | displacement | `--nv-lift-hover` `--nv-press-depth` `--nv-travel-sm/md/lg` | +| sequence | `--nv-stagger` — the gap between siblings arriving one after another | `prefers-reduced-motion` collapses every duration and displacement at the token level. Components never opt in individually. @@ -367,7 +450,9 @@ level. Components never opt in individually. `--nv-border-width-strong` · `--nv-target-min` · `--nv-layout-gutter` · `--nv-layout-max-width` · `--nv-selection-name` · `--nv-anchor` · `--nv-button-offset` · `--nv-bed-tint` · `--nv-scroller-item` · -`--nv-scroller-fade` · `--nv-content-name` +`--nv-scroller-fade` · `--nv-content-name` · `--nv-card-tint` · +`--nv-tile-tint` · `--nv-avatar-tint` · `--nv-pointer-x` / `--nv-pointer-y` +(set by `niveus.js`) · `--nv-reveal-index` (set by `niveus.js`) --- @@ -385,7 +470,7 @@ everything still renders — state changes simply arrive instead of travelling. | `nv.selectTab` | `(tablist, tab) => Promise` | Moves selection and its panel, with direction. | | `nv.initScroller` | `(frame) => void` | Wires one scroller's controls. | | `nv.liftLabels` | `(scope) => Element[]` | Names the navigation items in `scope` so a travelling marker passes behind them, and returns them. `nv.transition` does this for its `scope` and removes the names afterwards. | -| `nv.wire` | `(scope = document) => void` | Applies all of the below. Runs once on load; call again after injecting markup. | +| `nv.wire` | `(scope = document) => void` | Applies all of the below, and starts watching `.nv-reveal` / `.nv-reveal-group`. Runs once on load; call again after injecting markup. | | `nv.prefersReducedMotion` | `() => boolean` | | ### Data attributes @@ -402,6 +487,7 @@ everything still renders — state changes simply arrive instead of travelling. | `data-scrolled` | `.nv-nav` | Set by the application on scroll; gives the bar its shadow. | | `data-nv-direction` | `` | Set during a transition by `nv.transition`. | | `data-nv-transition` | `` | `"theme"` during an appearance change. | +| `data-nv-reveal` | revealed element | `pending` while waiting to scroll into view, `shown` while arriving; removed once it has. Set by `niveus.js`. | | `data-nv-travelling` | selection group | Set by `nv.transition` on each `scope` for the length of the change; names its marker. | --- diff --git a/dist/niveus.css b/dist/niveus.css index df81dac..526faca 100644 --- a/dist/niveus.css +++ b/dist/niveus.css @@ -302,6 +302,11 @@ --nv-color-sheen-strong-top: light-dark(var(--nv-light-sheen-strong-top), var(--nv-dark-sheen-strong-top)); --nv-color-sheen-strong-bottom: light-dark(var(--nv-light-sheen-strong-bottom), var(--nv-dark-sheen-strong-bottom)); + /* Glow: light a surface gives off, as opposed to the shade it casts. It is + the accent seen through air — strong enough in dark to read as a lit + edge, faint in light, where paper does not emit. */ + --nv-color-glow: light-dark(var(--nv-light-glow), var(--nv-dark-glow)); + /* A knob: the thumb of a switch or a slider. It stays light in both appearances, the way a physical knob does not change color with the room. */ --nv-color-knob: light-dark(var(--nv-light-knob), var(--nv-dark-knob)); @@ -598,6 +603,32 @@ --nv-focus-ring: 0 0 0 1px var(--nv-color-focus-ring-contrast), 0 0 0 calc(1px + var(--nv-focus-ring-width)) var(--nv-color-focus-ring); + + /* --- Glow ------------------------------------------------------------------ + Light a surface emits rather than the shade it casts. Glow is for things + that are *on* — a live status, the selected item in a showcase, a lit key — + and it always sits beside an elevation, never instead of one: a glowing + surface still rests on something. + + The geometry is a scale like elevation. The color is the component's: a + tinted surface glows in its own tint, mixed down to --nv-glow-strength. */ + --nv-glow-strength: 40%; + --nv-glow-blur-sm: 12px; + --nv-glow-spread-sm: -2px; + --nv-glow-blur-md: 28px; + --nv-glow-spread-md: -6px; + --nv-glow-blur-lg: 64px; + --nv-glow-spread-lg: -12px; + } + + /* The accent glows are declared on every branded subtree as well as the root: + a var() inside a custom property resolves where the property is declared, so + a root-only declaration would glow in the root brand everywhere. */ + :root, + [data-nv-brand] { + --nv-glow-sm: 0 0 var(--nv-glow-blur-sm) var(--nv-glow-spread-sm) var(--nv-color-glow); + --nv-glow-md: 0 0 var(--nv-glow-blur-md) var(--nv-glow-spread-md) var(--nv-color-glow); + --nv-glow-lg: 0 0 var(--nv-glow-blur-lg) var(--nv-glow-spread-lg) var(--nv-color-glow); } } @layer niveus.tokens { @@ -685,6 +716,15 @@ --nv-travel-sm: 4px; --nv-travel-md: 8px; --nv-travel-lg: 16px; + + /* --- Sequence --------------------------------------------------------------- + The gap between siblings arriving one after another. Short enough that a + group reads as one movement, long enough that the eye sees an order. */ + --nv-stagger: 60ms; + + /* A loading shimmer is a slow, constant sweep — slow enough to read as + "waiting", never fast enough to read as an alert. */ + --nv-duration-shimmer: 1600ms; } /* Reduced motion is a system-wide contract, not a per-component opt-in: cut @@ -706,6 +746,7 @@ --nv-travel-sm: 0px; --nv-travel-md: 0px; --nv-travel-lg: 0px; + --nv-stagger: 0ms; } } } @@ -965,6 +1006,7 @@ --nv-light-sheen-bottom: rgb(40 32 20 / 0.07); --nv-light-sheen-strong-top: rgb(255 255 255 / 0.32); --nv-light-sheen-strong-bottom: rgb(20 16 10 / 0.16); + --nv-light-glow: oklch(from var(--nv-light-accent) l c h / 0.3); --nv-light-knob: var(--nv-palette-neutral-0); --nv-light-knob-shade: rgb(40 32 20 / 0.13); @@ -1126,6 +1168,7 @@ --nv-dark-sheen-bottom: rgb(0 0 0 / 0.18); --nv-dark-sheen-strong-top: rgb(255 255 255 / 0.3); --nv-dark-sheen-strong-bottom: rgb(0 0 0 / 0.2); + --nv-dark-glow: oklch(from var(--nv-dark-accent) l c h / 0.5); --nv-dark-knob: #f4f3ef; --nv-dark-knob-shade: rgb(40 32 20 / 0.3); @@ -1364,6 +1407,35 @@ .nv-text-success { color: var(--nv-color-success-text); } .nv-text-warning { color: var(--nv-color-warning-text); } .nv-text-danger { color: var(--nv-color-danger-text); } + + /* Lit text: type cut from the same material as the surfaces — bright along + the top, warming towards the accent below, the way light falls across a + raised face. Each line is lit on its own. For one display line on a page, + not for running text. */ + .nv-text-lit { + background-image: linear-gradient( + 180deg, + var(--nv-color-text-primary) 30%, + color-mix(in oklab, var(--nv-color-accent-text) 30%, var(--nv-color-text-primary)) + ); + -webkit-box-decoration-break: clone; + box-decoration-break: clone; + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + color: var(--nv-color-accent-text); + } + + /* The accent alone, lit from above: for an accented phrase inside a display + line, where flat accent text would read as a link. */ + .nv-text-lit--accent { + background-image: linear-gradient( + 180deg, + color-mix(in oklab, var(--nv-color-accent-text) 55%, var(--nv-color-text-primary)), + var(--nv-color-accent-text) 60%, + var(--nv-color-accent) + ); + } } /* --- Continuity: how one state becomes the next ----------------------------- */ @@ -1570,6 +1642,33 @@ view-transition-class: nv-content; } + /* --- Reveal ------------------------------------------------------------------------ + Content arriving as it scrolls into view: it rises a short way and fades in, + once, so a long page is read in the order it was written instead of all at + once. .nv-reveal-group does the same for its children one after another, + --nv-stagger apart. + + niveus.js marks what is waiting. Without the script, and under reduced + motion, nothing is ever marked — so nothing is ever hidden. */ + + .nv-reveal[data-nv-reveal="pending"], + .nv-reveal-group > [data-nv-reveal="pending"] { + opacity: 0; + } + + .nv-reveal[data-nv-reveal="shown"], + .nv-reveal-group > [data-nv-reveal="shown"] { + animation: nv-arrive var(--nv-duration-slower) var(--nv-ease-entrance) + calc(var(--nv-reveal-index, 0) * var(--nv-stagger)) both; + } + + @keyframes nv-arrive { + from { + opacity: 0; + translate: 0 var(--nv-travel-lg); + } + } + /* Collapsible region for markup that is not a
    . The 0fr -> 1fr grid row is the one technique that animates to an unknown content height in every browser, with no measurement and no script. */ @@ -2473,6 +2572,9 @@ .nv-card--inset recessed into its parent .nv-card--frosted over imagery or scrolling content .nv-card--interactive the whole card is one target + .nv-card--lit lit along its top edge in its tint + .nv-card--glow gives off its tint — the one card that is on + .nv-card--spotlight catches a light that follows the pointer .nv-card__header / __body / __footer / __media A card is a container, not a look. If a card needs a shadow that is not on @@ -2490,6 +2592,11 @@ --_shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1); --_sheen: var(--nv-sheen-surface); --_padding: var(--nv-space-5); + /* Light layers the variants switch on. Kept separate from --_shadow so a + lit or glowing card can still be elevated, floating or interactive. */ + --_lit: 0 0 transparent; + --_glow: 0 0 transparent; + --_spot: linear-gradient(transparent, transparent); position: relative; display: flex; @@ -2497,11 +2604,11 @@ min-inline-size: 0; background-color: var(--_bg); - background-image: var(--_sheen); + background-image: var(--_spot), var(--_sheen); color: var(--nv-color-text-primary); border: var(--nv-border-width) solid var(--_border); border-radius: var(--nv-radius-lg); - box-shadow: var(--_shadow); + box-shadow: var(--_lit), var(--_shadow), var(--_glow); backdrop-filter: var(--nv-backdrop-glass); -webkit-backdrop-filter: var(--nv-backdrop-glass); @@ -2553,20 +2660,75 @@ @media (hover: hover) { .nv-card--interactive:hover { - box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-3); + box-shadow: var(--_lit), var(--nv-bevel), var(--nv-outline), var(--nv-elevation-3), var(--_glow); translate: 0 calc(var(--nv-lift-hover) * 2); } } .nv-card--interactive:active { - box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1); + box-shadow: var(--_lit), var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1), var(--_glow); translate: 0 var(--nv-press-depth); transition-duration: var(--nv-duration-instant); } .nv-card--interactive:focus-visible { outline: none; - box-shadow: var(--_shadow), var(--nv-focus-ring); + box-shadow: var(--_lit), var(--_shadow), var(--_glow), var(--nv-focus-ring); + } + + /* --- Light ------------------------------------------------------------------ + Three ways a card can carry light, each stated once and combinable with + every elevation above. They all take the card's tint — the accent unless + --nv-card-tint names another color, the way --nv-bed-tint does for a bed. + + Lit: a strip of light along the top edge, spilling a little way down + the face. For a card that belongs to something with a color: a + project, a status, a category. + Glow: the card gives off its tint. For the one card that is on — the + current plan, the live item. Two glowing cards side by side + cancel each other out. + Spotlight: a soft light follows the pointer across the face (niveus.js + tracks it). A showcase effect; a list of work items does not + need one. */ + + .nv-card--lit, + .nv-card--glow, + .nv-card--spotlight { + --nv-card-tint: var(--nv-color-accent); + } + + .nv-card--lit { + --_lit: + inset 0 2px 0 color-mix(in oklab, var(--nv-card-tint) 85%, transparent), + inset 0 18px 28px -22px color-mix(in oklab, var(--nv-card-tint) 70%, transparent); + --_sheen: + radial-gradient( + 120% 70% at 0% 0%, + color-mix(in oklab, var(--nv-card-tint) 12%, transparent), + transparent 70% + ), + var(--nv-sheen-surface); + } + + .nv-card--glow { + --_border: color-mix(in oklab, var(--nv-card-tint) 40%, var(--nv-color-border-subtle)); + --_glow: 0 0 var(--nv-glow-blur-md) var(--nv-glow-spread-md) + color-mix(in oklab, var(--nv-card-tint) var(--nv-glow-strength), transparent); + } + + @media (hover: hover) { + .nv-card--glow.nv-card--interactive:hover { + --_glow: 0 0 var(--nv-glow-blur-lg) var(--nv-glow-spread-md) + color-mix(in oklab, var(--nv-card-tint) var(--nv-glow-strength), transparent); + } + + .nv-card--spotlight:hover { + --_spot: radial-gradient( + 22rem circle at var(--nv-pointer-x, 50%) var(--nv-pointer-y, 0%), + color-mix(in oklab, var(--nv-card-tint) 14%, var(--nv-color-sheen-top)), + transparent 70% + ); + } } /* Cards whose whole area is clickable via a link inside: the link's target is @@ -4164,6 +4326,560 @@ overflow: auto; } } +@layer niveus.components { + /* ========================================================================== + Niveus — Tile + -------------------------------------------------------------------------- + + + A tile is a small raised key that holds one glyph: the icon beside a + feature, the mark of a product, the symbol on a stat. It is the physical + version of an icon — something with an edge you could press — which is why + a row of features reads as a set of objects rather than as clip art. + + .nv-tile soft key: tinted face, the glyph in its tint + .nv-tile--solid filled key: saturated face, a light glyph + .nv-tile--glow the key is lit from inside + .nv-tile--sm / --lg / --xl + + Everything derives from --nv-tile-tint, the accent unless set. A tile is + not a button: if it does something, it is an .nv-button--icon. + ========================================================================== */ + + .nv-tile { + --nv-tile-tint: var(--nv-color-accent); + --_size: 2.75rem; + --_radius: var(--nv-radius-md); + --_bg: color-mix(in oklab, var(--nv-tile-tint) 16%, var(--nv-color-surface-elevated)); + --_ink: color-mix(in oklab, var(--nv-tile-tint) 70%, var(--nv-color-text-primary)); + --_sheen: var(--nv-sheen-raised); + --_glow: 0 0 transparent; + + position: relative; + display: inline-grid; + place-items: center; + flex: none; + inline-size: var(--_size); + block-size: var(--_size); + + background-color: var(--_bg); + background-image: var(--_sheen); + color: var(--_ink); + border: var(--nv-border-width) solid color-mix(in oklab, var(--nv-tile-tint) 30%, var(--nv-color-border-subtle)); + border-radius: var(--_radius); + box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1), var(--_glow); + transition: var(--nv-transition-surface); + } + + /* Glyphs take half the key; a mark that is itself a picture (an image, a + logo) fills it, clipped to the key's corners. */ + .nv-tile > svg { + inline-size: 50%; + block-size: 50%; + } + + .nv-tile > img { + inline-size: 100%; + block-size: 100%; + border-radius: inherit; + object-fit: cover; + } + + .nv-tile--solid { + --_bg: var(--nv-tile-tint); + --_ink: var(--nv-color-on-accent); + --_sheen: var(--nv-sheen-filled); + } + + .nv-tile--glow { + --_glow: 0 0 var(--nv-glow-blur-md) var(--nv-glow-spread-sm) + color-mix(in oklab, var(--nv-tile-tint) var(--nv-glow-strength), transparent); + } + + .nv-tile--sm { + --_size: 2rem; + --_radius: var(--nv-radius-sm); + } + + .nv-tile--lg { + --_size: 3.5rem; + --_radius: var(--nv-radius-lg); + } + + .nv-tile--xl { + --_size: 4.5rem; + --_radius: var(--nv-radius-xl); + } +} +@layer niveus.components { + /* ========================================================================== + Niveus — Avatar + -------------------------------------------------------------------------- + + NX + + A person, as a round raised disc: an image when there is one, initials when + there is not. The disc is lit like a knob — highlight above, shade below — + so a face reads as something set into the interface, not pasted onto it. + + .nv-avatar--ring a ring in the tint, set off by a gap of surface + .nv-avatar--glow the ring gives off its tint + .nv-avatar--sm / --lg / --xl + .nv-avatar__status presence dot; --online lights it + .nv-avatar-group overlapping row of avatars + + --nv-avatar-tint picks the ring color; the accent unless set. + ========================================================================== */ + + .nv-avatar { + --nv-avatar-tint: var(--nv-color-accent); + --_size: 2.5rem; + --_ring: 0 0 transparent; + --_glow: 0 0 transparent; + + position: relative; + display: inline-grid; + place-items: center; + flex: none; + inline-size: var(--_size); + block-size: var(--_size); + + background-color: var(--nv-color-surface-elevated); + background-image: var(--nv-sheen-raised); + color: var(--nv-color-text-secondary); + font: var(--nv-font-label); + font-weight: var(--nv-font-weight-semibold); + border-radius: var(--nv-radius-full); + box-shadow: var(--_ring), var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1), var(--_glow); + user-select: none; + } + + .nv-avatar > img { + inline-size: 100%; + block-size: 100%; + border-radius: inherit; + object-fit: cover; + } + + /* The gap is drawn in the surface color, so the ring reads as a separate band + sitting around the disc rather than as a thick border painted onto it. */ + .nv-avatar--ring, + .nv-avatar--glow { + --_ring: + 0 0 0 2px var(--nv-color-surface), + 0 0 0 4px var(--nv-avatar-tint); + } + + .nv-avatar--glow { + --_glow: 0 0 var(--nv-glow-blur-md) var(--nv-glow-spread-sm) + color-mix(in oklab, var(--nv-avatar-tint) var(--nv-glow-strength), transparent); + } + + .nv-avatar--sm { + --_size: 1.75rem; + font: var(--nv-font-caption); + } + + .nv-avatar--lg { + --_size: 4rem; + font: var(--nv-font-title); + } + + .nv-avatar--xl { + --_size: 6rem; + font: var(--nv-font-heading); + } + + /* --- Status ------------------------------------------------------------------ */ + + .nv-avatar__status { + position: absolute; + inset-block-end: 4%; + inset-inline-end: 4%; + inline-size: max(0.625rem, 24%); + block-size: max(0.625rem, 24%); + border-radius: var(--nv-radius-full); + background-color: var(--nv-color-text-tertiary); + box-shadow: 0 0 0 2px var(--nv-color-surface); + } + + .nv-avatar__status--online { + background-color: var(--nv-color-success); + box-shadow: + 0 0 0 2px var(--nv-color-surface), + 0 0 var(--nv-glow-blur-sm) var(--nv-glow-spread-sm) + color-mix(in oklab, var(--nv-color-success) var(--nv-glow-strength), transparent); + } + + /* --- Group ----------------------------------------------------------------------- */ + + .nv-avatar-group { + display: inline-flex; + align-items: center; + } + + .nv-avatar-group > .nv-avatar { + box-shadow: 0 0 0 2px var(--nv-color-surface), var(--nv-bevel), var(--nv-elevation-1); + } + + .nv-avatar-group > .nv-avatar + .nv-avatar { + margin-inline-start: calc(var(--_size) * -0.3); + } +} +@layer niveus.components { + /* ========================================================================== + Niveus — Stat + -------------------------------------------------------------------------- +
    +
    +
    Servers
    +
    1,204
    +
    +
    + + A number the user is meant to read at a glance. A group of stats is set into + one shallow well, like the readout panel of an instrument: the figures sit + behind the surface, separated by hairlines, so a row of numbers reads as one + display instead of as four loose labels. + + .nv-stat-group the readout well; wraps without a media query + .nv-stat one reading + .nv-stat__label what is measured + .nv-stat__value the figure, in tabular numerals + .nv-stat__delta change since last time; --up / --down + ========================================================================== */ + + .nv-stat-group { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(8rem, 100%), 1fr)); + /* The hairlines between readings are the well itself showing through a + one-pixel gap, so they stay continuous however the row wraps. */ + gap: var(--nv-border-width); + margin: 0; + background-color: var(--nv-color-border-subtle); + border: var(--nv-border-width) solid var(--nv-color-border-subtle); + border-radius: var(--nv-radius-lg); + box-shadow: var(--nv-elevation-inset-md), var(--nv-ledge); + overflow: hidden; + } + + .nv-stat { + display: flex; + flex-direction: column; + gap: var(--nv-space-1); + min-inline-size: 0; + padding: var(--nv-space-4) var(--nv-space-5); + } + + .nv-stat-group > .nv-stat { + background-color: var(--nv-color-surface-inset); + } + + .nv-stat__label { + font: var(--nv-font-caption); + color: var(--nv-color-text-tertiary); + } + + .nv-stat__value { + margin: 0; + font: var(--nv-font-heading); + font-variant-numeric: tabular-nums; + letter-spacing: var(--nv-letter-spacing-tight); + color: var(--nv-color-text-primary); + } + + .nv-stat__delta { + font: var(--nv-font-caption); + font-variant-numeric: tabular-nums; + color: var(--nv-color-text-secondary); + } + + .nv-stat__delta--up { + color: var(--nv-color-success); + } + + .nv-stat__delta--down { + color: var(--nv-color-danger); + } +} +@layer niveus.components { + /* ========================================================================== + Niveus — Steps + -------------------------------------------------------------------------- +
      +
    1. + 1 +
      +

      Listen

      +

      …

      +
      +
    2. +
    + + A sequence the user moves through: a process, an onboarding, an order's + progress. The steps sit on a rail — a groove cut into the surface — with + each marker a raised knob set into it. Finished steps light their stretch + of the rail, so progress reads as something filling up rather than as a + row of ticks. + + .nv-steps horizontal; becomes vertical on narrow screens + .nv-steps--vertical always vertical + data-state="done" the step and its stretch of rail are lit + aria-current="step" the step in progress: lit marker, rail still dark + ========================================================================== */ + + .nv-steps { + --_marker: 2.25rem; + --_rail: 6px; + --_gap: var(--nv-space-4); + + display: grid; + grid-auto-flow: column; + grid-auto-columns: minmax(0, 1fr); + gap: var(--_gap); + margin: 0; + padding: 0; + list-style: none; + } + + .nv-steps__item { + position: relative; + display: flex; + flex-direction: column; + gap: var(--nv-space-3); + min-inline-size: 0; + } + + /* Each step draws the rail from its own marker to the next one, so a finished + step can light exactly its stretch and the rail never runs past the end. */ + .nv-steps__item:not(:last-child)::before { + content: ""; + position: absolute; + inset-block-start: calc((var(--_marker) - var(--_rail)) / 2); + inset-inline-start: calc(var(--_marker) / 2); + inline-size: calc(100% + var(--_gap)); + block-size: var(--_rail); + border-radius: var(--nv-radius-full); + background-color: var(--nv-color-surface-inset); + box-shadow: var(--nv-elevation-inset-sm), var(--nv-ledge); + transition: var(--nv-transition-surface); + } + + .nv-steps__item[data-state="done"]:not(:last-child)::before { + background-color: var(--nv-color-accent); + background-image: var(--nv-sheen-filled); + box-shadow: var(--nv-glow-sm); + } + + .nv-steps__marker { + position: relative; + z-index: 1; + display: grid; + place-items: center; + flex: none; + inline-size: var(--_marker); + block-size: var(--_marker); + + background-color: var(--nv-color-surface-elevated); + background-image: var(--nv-sheen-raised); + color: var(--nv-color-text-secondary); + font: var(--nv-font-label); + font-weight: var(--nv-font-weight-semibold); + font-variant-numeric: tabular-nums; + border-radius: var(--nv-radius-full); + box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-2); + transition: var(--nv-transition-surface); + } + + .nv-steps__item[data-state="done"] > .nv-steps__marker, + .nv-steps__item[aria-current="step"] > .nv-steps__marker { + background-color: var(--nv-color-accent); + background-image: var(--nv-sheen-filled); + color: var(--nv-color-on-accent); + box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-2), var(--nv-glow-md); + } + + .nv-steps__body { + display: flex; + flex-direction: column; + gap: var(--nv-space-1); + min-inline-size: 0; + } + + .nv-steps__title { + margin: 0; + font: var(--nv-font-title); + letter-spacing: var(--nv-letter-spacing-tight); + color: var(--nv-color-text-primary); + } + + .nv-steps__text { + margin: 0; + font: var(--nv-font-caption); + color: var(--nv-color-text-secondary); + } + + /* --- Vertical ------------------------------------------------------------------- + Marker beside the text and the rail running down between markers. Also what + a horizontal sequence becomes once its columns are too narrow to read. */ + + .nv-steps--vertical { + grid-auto-flow: row; + grid-auto-columns: auto; + --_gap: var(--nv-space-5); + } + + .nv-steps--vertical > .nv-steps__item { + flex-direction: row; + gap: var(--nv-space-4); + } + + .nv-steps--vertical > .nv-steps__item:not(:last-child)::before { + inset-block-start: calc(var(--_marker) / 2); + inset-inline-start: calc((var(--_marker) - var(--_rail)) / 2); + inline-size: var(--_rail); + block-size: calc(100% + var(--_gap)); + } + + @media (max-width: 40rem) { + .nv-steps { + grid-auto-flow: row; + grid-auto-columns: auto; + --_gap: var(--nv-space-5); + } + + .nv-steps > .nv-steps__item { + flex-direction: row; + gap: var(--nv-space-4); + } + + .nv-steps > .nv-steps__item:not(:last-child)::before { + inset-block-start: calc(var(--_marker) / 2); + inset-inline-start: calc((var(--_marker) - var(--_rail)) / 2); + inline-size: var(--_rail); + block-size: calc(100% + var(--_gap)); + } + } +} +@layer niveus.components { + /* ========================================================================== + Niveus — Deck + -------------------------------------------------------------------------- +
    +
    …
    + + +
    + + One surface in front of others of its kind: the current item of a set that + has more behind it. The layers are cards seen edge-on, stepping back and + dimming with distance, so the set has depth without anything having to + move. Use it where "there are more of these" is part of the message — a + showcase of products, a stack of notifications, a carousel at rest. + + Everything in the deck shares one grid cell. The front surface is any + element; the layers are decorative and hidden from assistive technology. + Panels that swap (an .nv-tab-panel set) can live in the deck together: + the hidden ones take no space. + ========================================================================== */ + + .nv-deck { + --_step: var(--nv-space-3); + + display: grid; + position: relative; + isolation: isolate; + /* Room above the front surface for the layers that peek out behind it. */ + padding-block-start: calc(var(--_step) * 2); + } + + .nv-deck > * { + grid-area: 1 / 1; + min-inline-size: 0; + } + + .nv-deck__layer { + --_depth: 1; + + z-index: -1; + pointer-events: none; + background-color: color-mix(in oklab, var(--nv-color-surface) 90%, transparent); + background-image: var(--nv-sheen-surface); + border: var(--nv-border-width) solid var(--nv-color-border-subtle); + border-radius: var(--nv-radius-lg); + box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1); + translate: 0 calc(var(--_step) * var(--_depth) * -1); + scale: calc(1 - var(--_depth) * 0.05) 1; + opacity: calc(1 - var(--_depth) * 0.3); + } + + .nv-deck__layer + .nv-deck__layer { + --_depth: 2; + z-index: -2; + } +} +@layer niveus.components { + /* ========================================================================== + Niveus — Skeleton + -------------------------------------------------------------------------- + + + The shape of content that has not arrived yet. A skeleton is a shallow well + cut where the content will sit, with a slow band of light crossing it — the + surface is recessed and waiting, not broken. + + .nv-skeleton a block; size it with the layout + .nv-skeleton--text one line of body text + .nv-skeleton--circle an avatar or a tile + + Under reduced motion the light stops crossing and the well stays: the state + is still legible, it just does not move. + ========================================================================== */ + + .nv-skeleton { + display: block; + min-block-size: var(--nv-space-4); + border-radius: var(--nv-radius-md); + background-color: var(--nv-color-surface-inset); + background-image: linear-gradient( + 100deg, + transparent 30%, + var(--nv-color-sheen-top) 50%, + transparent 70% + ); + background-size: 220% 100%; + box-shadow: var(--nv-elevation-inset-sm), var(--nv-ledge); + animation: nv-shimmer var(--nv-duration-shimmer) var(--nv-ease-linear) infinite; + } + + .nv-skeleton--text { + inline-size: 100%; + min-block-size: 0; + block-size: 0.75em; + border-radius: var(--nv-radius-full); + } + + /* A paragraph of skeleton lines ends short, like the paragraph it stands for. */ + .nv-skeleton--text:last-child:not(:first-child) { + inline-size: 60%; + } + + .nv-skeleton--circle { + aspect-ratio: 1; + border-radius: var(--nv-radius-full); + } + + @keyframes nv-shimmer { + from { background-position: 100% 0; } + to { background-position: -120% 0; } + } + + @media (prefers-reduced-motion: reduce) { + .nv-skeleton { + animation: none; + } + } +} /* --- Utilities ----------------------------------------------------------------- */ @layer niveus.utilities { diff --git a/niveus/base.css b/niveus/base.css index 3f1993f..5c752e3 100644 --- a/niveus/base.css +++ b/niveus/base.css @@ -204,3 +204,32 @@ textarea { .nv-text-success { color: var(--nv-color-success-text); } .nv-text-warning { color: var(--nv-color-warning-text); } .nv-text-danger { color: var(--nv-color-danger-text); } + +/* Lit text: type cut from the same material as the surfaces — bright along + the top, warming towards the accent below, the way light falls across a + raised face. Each line is lit on its own. For one display line on a page, + not for running text. */ +.nv-text-lit { + background-image: linear-gradient( + 180deg, + var(--nv-color-text-primary) 30%, + color-mix(in oklab, var(--nv-color-accent-text) 30%, var(--nv-color-text-primary)) + ); + -webkit-box-decoration-break: clone; + box-decoration-break: clone; + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + color: var(--nv-color-accent-text); +} + +/* The accent alone, lit from above: for an accented phrase inside a display + line, where flat accent text would read as a link. */ +.nv-text-lit--accent { + background-image: linear-gradient( + 180deg, + color-mix(in oklab, var(--nv-color-accent-text) 55%, var(--nv-color-text-primary)), + var(--nv-color-accent-text) 60%, + var(--nv-color-accent) + ); +} diff --git a/niveus/components/avatar.css b/niveus/components/avatar.css new file mode 100644 index 0000000..20f5f36 --- /dev/null +++ b/niveus/components/avatar.css @@ -0,0 +1,113 @@ +/* ========================================================================== + Niveus — Avatar + -------------------------------------------------------------------------- + + NX + + A person, as a round raised disc: an image when there is one, initials when + there is not. The disc is lit like a knob — highlight above, shade below — + so a face reads as something set into the interface, not pasted onto it. + + .nv-avatar--ring a ring in the tint, set off by a gap of surface + .nv-avatar--glow the ring gives off its tint + .nv-avatar--sm / --lg / --xl + .nv-avatar__status presence dot; --online lights it + .nv-avatar-group overlapping row of avatars + + --nv-avatar-tint picks the ring color; the accent unless set. + ========================================================================== */ + +.nv-avatar { + --nv-avatar-tint: var(--nv-color-accent); + --_size: 2.5rem; + --_ring: 0 0 transparent; + --_glow: 0 0 transparent; + + position: relative; + display: inline-grid; + place-items: center; + flex: none; + inline-size: var(--_size); + block-size: var(--_size); + + background-color: var(--nv-color-surface-elevated); + background-image: var(--nv-sheen-raised); + color: var(--nv-color-text-secondary); + font: var(--nv-font-label); + font-weight: var(--nv-font-weight-semibold); + border-radius: var(--nv-radius-full); + box-shadow: var(--_ring), var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1), var(--_glow); + user-select: none; +} + +.nv-avatar > img { + inline-size: 100%; + block-size: 100%; + border-radius: inherit; + object-fit: cover; +} + +/* The gap is drawn in the surface color, so the ring reads as a separate band + sitting around the disc rather than as a thick border painted onto it. */ +.nv-avatar--ring, +.nv-avatar--glow { + --_ring: + 0 0 0 2px var(--nv-color-surface), + 0 0 0 4px var(--nv-avatar-tint); +} + +.nv-avatar--glow { + --_glow: 0 0 var(--nv-glow-blur-md) var(--nv-glow-spread-sm) + color-mix(in oklab, var(--nv-avatar-tint) var(--nv-glow-strength), transparent); +} + +.nv-avatar--sm { + --_size: 1.75rem; + font: var(--nv-font-caption); +} + +.nv-avatar--lg { + --_size: 4rem; + font: var(--nv-font-title); +} + +.nv-avatar--xl { + --_size: 6rem; + font: var(--nv-font-heading); +} + +/* --- Status ------------------------------------------------------------------ */ + +.nv-avatar__status { + position: absolute; + inset-block-end: 4%; + inset-inline-end: 4%; + inline-size: max(0.625rem, 24%); + block-size: max(0.625rem, 24%); + border-radius: var(--nv-radius-full); + background-color: var(--nv-color-text-tertiary); + box-shadow: 0 0 0 2px var(--nv-color-surface); +} + +.nv-avatar__status--online { + background-color: var(--nv-color-success); + box-shadow: + 0 0 0 2px var(--nv-color-surface), + 0 0 var(--nv-glow-blur-sm) var(--nv-glow-spread-sm) + color-mix(in oklab, var(--nv-color-success) var(--nv-glow-strength), transparent); +} + +/* --- Group ----------------------------------------------------------------------- */ + +.nv-avatar-group { + display: inline-flex; + align-items: center; +} + +.nv-avatar-group > .nv-avatar { + box-shadow: 0 0 0 2px var(--nv-color-surface), var(--nv-bevel), var(--nv-elevation-1); +} + +.nv-avatar-group > .nv-avatar + .nv-avatar { + margin-inline-start: calc(var(--_size) * -0.3); +} diff --git a/niveus/components/card.css b/niveus/components/card.css index 0200ff6..4dd9646 100644 --- a/niveus/components/card.css +++ b/niveus/components/card.css @@ -6,6 +6,9 @@ .nv-card--inset recessed into its parent .nv-card--frosted over imagery or scrolling content .nv-card--interactive the whole card is one target + .nv-card--lit lit along its top edge in its tint + .nv-card--glow gives off its tint — the one card that is on + .nv-card--spotlight catches a light that follows the pointer .nv-card__header / __body / __footer / __media A card is a container, not a look. If a card needs a shadow that is not on @@ -23,6 +26,11 @@ --_shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1); --_sheen: var(--nv-sheen-surface); --_padding: var(--nv-space-5); + /* Light layers the variants switch on. Kept separate from --_shadow so a + lit or glowing card can still be elevated, floating or interactive. */ + --_lit: 0 0 transparent; + --_glow: 0 0 transparent; + --_spot: linear-gradient(transparent, transparent); position: relative; display: flex; @@ -30,11 +38,11 @@ min-inline-size: 0; background-color: var(--_bg); - background-image: var(--_sheen); + background-image: var(--_spot), var(--_sheen); color: var(--nv-color-text-primary); border: var(--nv-border-width) solid var(--_border); border-radius: var(--nv-radius-lg); - box-shadow: var(--_shadow); + box-shadow: var(--_lit), var(--_shadow), var(--_glow); backdrop-filter: var(--nv-backdrop-glass); -webkit-backdrop-filter: var(--nv-backdrop-glass); @@ -86,20 +94,75 @@ @media (hover: hover) { .nv-card--interactive:hover { - box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-3); + box-shadow: var(--_lit), var(--nv-bevel), var(--nv-outline), var(--nv-elevation-3), var(--_glow); translate: 0 calc(var(--nv-lift-hover) * 2); } } .nv-card--interactive:active { - box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1); + box-shadow: var(--_lit), var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1), var(--_glow); translate: 0 var(--nv-press-depth); transition-duration: var(--nv-duration-instant); } .nv-card--interactive:focus-visible { outline: none; - box-shadow: var(--_shadow), var(--nv-focus-ring); + box-shadow: var(--_lit), var(--_shadow), var(--_glow), var(--nv-focus-ring); +} + +/* --- Light ------------------------------------------------------------------ + Three ways a card can carry light, each stated once and combinable with + every elevation above. They all take the card's tint — the accent unless + --nv-card-tint names another color, the way --nv-bed-tint does for a bed. + + Lit: a strip of light along the top edge, spilling a little way down + the face. For a card that belongs to something with a color: a + project, a status, a category. + Glow: the card gives off its tint. For the one card that is on — the + current plan, the live item. Two glowing cards side by side + cancel each other out. + Spotlight: a soft light follows the pointer across the face (niveus.js + tracks it). A showcase effect; a list of work items does not + need one. */ + +.nv-card--lit, +.nv-card--glow, +.nv-card--spotlight { + --nv-card-tint: var(--nv-color-accent); +} + +.nv-card--lit { + --_lit: + inset 0 2px 0 color-mix(in oklab, var(--nv-card-tint) 85%, transparent), + inset 0 18px 28px -22px color-mix(in oklab, var(--nv-card-tint) 70%, transparent); + --_sheen: + radial-gradient( + 120% 70% at 0% 0%, + color-mix(in oklab, var(--nv-card-tint) 12%, transparent), + transparent 70% + ), + var(--nv-sheen-surface); +} + +.nv-card--glow { + --_border: color-mix(in oklab, var(--nv-card-tint) 40%, var(--nv-color-border-subtle)); + --_glow: 0 0 var(--nv-glow-blur-md) var(--nv-glow-spread-md) + color-mix(in oklab, var(--nv-card-tint) var(--nv-glow-strength), transparent); +} + +@media (hover: hover) { + .nv-card--glow.nv-card--interactive:hover { + --_glow: 0 0 var(--nv-glow-blur-lg) var(--nv-glow-spread-md) + color-mix(in oklab, var(--nv-card-tint) var(--nv-glow-strength), transparent); + } + + .nv-card--spotlight:hover { + --_spot: radial-gradient( + 22rem circle at var(--nv-pointer-x, 50%) var(--nv-pointer-y, 0%), + color-mix(in oklab, var(--nv-card-tint) 14%, var(--nv-color-sheen-top)), + transparent 70% + ); + } } /* Cards whose whole area is clickable via a link inside: the link's target is diff --git a/niveus/components/deck.css b/niveus/components/deck.css new file mode 100644 index 0000000..3131d0b --- /dev/null +++ b/niveus/components/deck.css @@ -0,0 +1,55 @@ +/* ========================================================================== + Niveus — Deck + -------------------------------------------------------------------------- +
    +
    …
    + + +
    + + One surface in front of others of its kind: the current item of a set that + has more behind it. The layers are cards seen edge-on, stepping back and + dimming with distance, so the set has depth without anything having to + move. Use it where "there are more of these" is part of the message — a + showcase of products, a stack of notifications, a carousel at rest. + + Everything in the deck shares one grid cell. The front surface is any + element; the layers are decorative and hidden from assistive technology. + Panels that swap (an .nv-tab-panel set) can live in the deck together: + the hidden ones take no space. + ========================================================================== */ + +.nv-deck { + --_step: var(--nv-space-3); + + display: grid; + position: relative; + isolation: isolate; + /* Room above the front surface for the layers that peek out behind it. */ + padding-block-start: calc(var(--_step) * 2); +} + +.nv-deck > * { + grid-area: 1 / 1; + min-inline-size: 0; +} + +.nv-deck__layer { + --_depth: 1; + + z-index: -1; + pointer-events: none; + background-color: color-mix(in oklab, var(--nv-color-surface) 90%, transparent); + background-image: var(--nv-sheen-surface); + border: var(--nv-border-width) solid var(--nv-color-border-subtle); + border-radius: var(--nv-radius-lg); + box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1); + translate: 0 calc(var(--_step) * var(--_depth) * -1); + scale: calc(1 - var(--_depth) * 0.05) 1; + opacity: calc(1 - var(--_depth) * 0.3); +} + +.nv-deck__layer + .nv-deck__layer { + --_depth: 2; + z-index: -2; +} diff --git a/niveus/components/skeleton.css b/niveus/components/skeleton.css new file mode 100644 index 0000000..a79aec6 --- /dev/null +++ b/niveus/components/skeleton.css @@ -0,0 +1,60 @@ +/* ========================================================================== + Niveus — Skeleton + -------------------------------------------------------------------------- + + + The shape of content that has not arrived yet. A skeleton is a shallow well + cut where the content will sit, with a slow band of light crossing it — the + surface is recessed and waiting, not broken. + + .nv-skeleton a block; size it with the layout + .nv-skeleton--text one line of body text + .nv-skeleton--circle an avatar or a tile + + Under reduced motion the light stops crossing and the well stays: the state + is still legible, it just does not move. + ========================================================================== */ + +.nv-skeleton { + display: block; + min-block-size: var(--nv-space-4); + border-radius: var(--nv-radius-md); + background-color: var(--nv-color-surface-inset); + background-image: linear-gradient( + 100deg, + transparent 30%, + var(--nv-color-sheen-top) 50%, + transparent 70% + ); + background-size: 220% 100%; + box-shadow: var(--nv-elevation-inset-sm), var(--nv-ledge); + animation: nv-shimmer var(--nv-duration-shimmer) var(--nv-ease-linear) infinite; +} + +.nv-skeleton--text { + inline-size: 100%; + min-block-size: 0; + block-size: 0.75em; + border-radius: var(--nv-radius-full); +} + +/* A paragraph of skeleton lines ends short, like the paragraph it stands for. */ +.nv-skeleton--text:last-child:not(:first-child) { + inline-size: 60%; +} + +.nv-skeleton--circle { + aspect-ratio: 1; + border-radius: var(--nv-radius-full); +} + +@keyframes nv-shimmer { + from { background-position: 100% 0; } + to { background-position: -120% 0; } +} + +@media (prefers-reduced-motion: reduce) { + .nv-skeleton { + animation: none; + } +} diff --git a/niveus/components/stat.css b/niveus/components/stat.css new file mode 100644 index 0000000..7a140f0 --- /dev/null +++ b/niveus/components/stat.css @@ -0,0 +1,74 @@ +/* ========================================================================== + Niveus — Stat + -------------------------------------------------------------------------- +
    +
    +
    Servers
    +
    1,204
    +
    +
    + + A number the user is meant to read at a glance. A group of stats is set into + one shallow well, like the readout panel of an instrument: the figures sit + behind the surface, separated by hairlines, so a row of numbers reads as one + display instead of as four loose labels. + + .nv-stat-group the readout well; wraps without a media query + .nv-stat one reading + .nv-stat__label what is measured + .nv-stat__value the figure, in tabular numerals + .nv-stat__delta change since last time; --up / --down + ========================================================================== */ + +.nv-stat-group { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(8rem, 100%), 1fr)); + /* The hairlines between readings are the well itself showing through a + one-pixel gap, so they stay continuous however the row wraps. */ + gap: var(--nv-border-width); + margin: 0; + background-color: var(--nv-color-border-subtle); + border: var(--nv-border-width) solid var(--nv-color-border-subtle); + border-radius: var(--nv-radius-lg); + box-shadow: var(--nv-elevation-inset-md), var(--nv-ledge); + overflow: hidden; +} + +.nv-stat { + display: flex; + flex-direction: column; + gap: var(--nv-space-1); + min-inline-size: 0; + padding: var(--nv-space-4) var(--nv-space-5); +} + +.nv-stat-group > .nv-stat { + background-color: var(--nv-color-surface-inset); +} + +.nv-stat__label { + font: var(--nv-font-caption); + color: var(--nv-color-text-tertiary); +} + +.nv-stat__value { + margin: 0; + font: var(--nv-font-heading); + font-variant-numeric: tabular-nums; + letter-spacing: var(--nv-letter-spacing-tight); + color: var(--nv-color-text-primary); +} + +.nv-stat__delta { + font: var(--nv-font-caption); + font-variant-numeric: tabular-nums; + color: var(--nv-color-text-secondary); +} + +.nv-stat__delta--up { + color: var(--nv-color-success); +} + +.nv-stat__delta--down { + color: var(--nv-color-danger); +} diff --git a/niveus/components/steps.css b/niveus/components/steps.css new file mode 100644 index 0000000..6aa4598 --- /dev/null +++ b/niveus/components/steps.css @@ -0,0 +1,157 @@ +/* ========================================================================== + Niveus — Steps + -------------------------------------------------------------------------- +
      +
    1. + 1 +
      +

      Listen

      +

      …

      +
      +
    2. +
    + + A sequence the user moves through: a process, an onboarding, an order's + progress. The steps sit on a rail — a groove cut into the surface — with + each marker a raised knob set into it. Finished steps light their stretch + of the rail, so progress reads as something filling up rather than as a + row of ticks. + + .nv-steps horizontal; becomes vertical on narrow screens + .nv-steps--vertical always vertical + data-state="done" the step and its stretch of rail are lit + aria-current="step" the step in progress: lit marker, rail still dark + ========================================================================== */ + +.nv-steps { + --_marker: 2.25rem; + --_rail: 6px; + --_gap: var(--nv-space-4); + + display: grid; + grid-auto-flow: column; + grid-auto-columns: minmax(0, 1fr); + gap: var(--_gap); + margin: 0; + padding: 0; + list-style: none; +} + +.nv-steps__item { + position: relative; + display: flex; + flex-direction: column; + gap: var(--nv-space-3); + min-inline-size: 0; +} + +/* Each step draws the rail from its own marker to the next one, so a finished + step can light exactly its stretch and the rail never runs past the end. */ +.nv-steps__item:not(:last-child)::before { + content: ""; + position: absolute; + inset-block-start: calc((var(--_marker) - var(--_rail)) / 2); + inset-inline-start: calc(var(--_marker) / 2); + inline-size: calc(100% + var(--_gap)); + block-size: var(--_rail); + border-radius: var(--nv-radius-full); + background-color: var(--nv-color-surface-inset); + box-shadow: var(--nv-elevation-inset-sm), var(--nv-ledge); + transition: var(--nv-transition-surface); +} + +.nv-steps__item[data-state="done"]:not(:last-child)::before { + background-color: var(--nv-color-accent); + background-image: var(--nv-sheen-filled); + box-shadow: var(--nv-glow-sm); +} + +.nv-steps__marker { + position: relative; + z-index: 1; + display: grid; + place-items: center; + flex: none; + inline-size: var(--_marker); + block-size: var(--_marker); + + background-color: var(--nv-color-surface-elevated); + background-image: var(--nv-sheen-raised); + color: var(--nv-color-text-secondary); + font: var(--nv-font-label); + font-weight: var(--nv-font-weight-semibold); + font-variant-numeric: tabular-nums; + border-radius: var(--nv-radius-full); + box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-2); + transition: var(--nv-transition-surface); +} + +.nv-steps__item[data-state="done"] > .nv-steps__marker, +.nv-steps__item[aria-current="step"] > .nv-steps__marker { + background-color: var(--nv-color-accent); + background-image: var(--nv-sheen-filled); + color: var(--nv-color-on-accent); + box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-2), var(--nv-glow-md); +} + +.nv-steps__body { + display: flex; + flex-direction: column; + gap: var(--nv-space-1); + min-inline-size: 0; +} + +.nv-steps__title { + margin: 0; + font: var(--nv-font-title); + letter-spacing: var(--nv-letter-spacing-tight); + color: var(--nv-color-text-primary); +} + +.nv-steps__text { + margin: 0; + font: var(--nv-font-caption); + color: var(--nv-color-text-secondary); +} + +/* --- Vertical ------------------------------------------------------------------- + Marker beside the text and the rail running down between markers. Also what + a horizontal sequence becomes once its columns are too narrow to read. */ + +.nv-steps--vertical { + grid-auto-flow: row; + grid-auto-columns: auto; + --_gap: var(--nv-space-5); +} + +.nv-steps--vertical > .nv-steps__item { + flex-direction: row; + gap: var(--nv-space-4); +} + +.nv-steps--vertical > .nv-steps__item:not(:last-child)::before { + inset-block-start: calc(var(--_marker) / 2); + inset-inline-start: calc((var(--_marker) - var(--_rail)) / 2); + inline-size: var(--_rail); + block-size: calc(100% + var(--_gap)); +} + +@media (max-width: 40rem) { + .nv-steps { + grid-auto-flow: row; + grid-auto-columns: auto; + --_gap: var(--nv-space-5); + } + + .nv-steps > .nv-steps__item { + flex-direction: row; + gap: var(--nv-space-4); + } + + .nv-steps > .nv-steps__item:not(:last-child)::before { + inset-block-start: calc(var(--_marker) / 2); + inset-inline-start: calc((var(--_marker) - var(--_rail)) / 2); + inline-size: var(--_rail); + block-size: calc(100% + var(--_gap)); + } +} diff --git a/niveus/components/tile.css b/niveus/components/tile.css new file mode 100644 index 0000000..f8083b7 --- /dev/null +++ b/niveus/components/tile.css @@ -0,0 +1,83 @@ +/* ========================================================================== + Niveus — Tile + -------------------------------------------------------------------------- + + + A tile is a small raised key that holds one glyph: the icon beside a + feature, the mark of a product, the symbol on a stat. It is the physical + version of an icon — something with an edge you could press — which is why + a row of features reads as a set of objects rather than as clip art. + + .nv-tile soft key: tinted face, the glyph in its tint + .nv-tile--solid filled key: saturated face, a light glyph + .nv-tile--glow the key is lit from inside + .nv-tile--sm / --lg / --xl + + Everything derives from --nv-tile-tint, the accent unless set. A tile is + not a button: if it does something, it is an .nv-button--icon. + ========================================================================== */ + +.nv-tile { + --nv-tile-tint: var(--nv-color-accent); + --_size: 2.75rem; + --_radius: var(--nv-radius-md); + --_bg: color-mix(in oklab, var(--nv-tile-tint) 16%, var(--nv-color-surface-elevated)); + --_ink: color-mix(in oklab, var(--nv-tile-tint) 70%, var(--nv-color-text-primary)); + --_sheen: var(--nv-sheen-raised); + --_glow: 0 0 transparent; + + position: relative; + display: inline-grid; + place-items: center; + flex: none; + inline-size: var(--_size); + block-size: var(--_size); + + background-color: var(--_bg); + background-image: var(--_sheen); + color: var(--_ink); + border: var(--nv-border-width) solid color-mix(in oklab, var(--nv-tile-tint) 30%, var(--nv-color-border-subtle)); + border-radius: var(--_radius); + box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1), var(--_glow); + transition: var(--nv-transition-surface); +} + +/* Glyphs take half the key; a mark that is itself a picture (an image, a + logo) fills it, clipped to the key's corners. */ +.nv-tile > svg { + inline-size: 50%; + block-size: 50%; +} + +.nv-tile > img { + inline-size: 100%; + block-size: 100%; + border-radius: inherit; + object-fit: cover; +} + +.nv-tile--solid { + --_bg: var(--nv-tile-tint); + --_ink: var(--nv-color-on-accent); + --_sheen: var(--nv-sheen-filled); +} + +.nv-tile--glow { + --_glow: 0 0 var(--nv-glow-blur-md) var(--nv-glow-spread-sm) + color-mix(in oklab, var(--nv-tile-tint) var(--nv-glow-strength), transparent); +} + +.nv-tile--sm { + --_size: 2rem; + --_radius: var(--nv-radius-sm); +} + +.nv-tile--lg { + --_size: 3.5rem; + --_radius: var(--nv-radius-lg); +} + +.nv-tile--xl { + --_size: 4.5rem; + --_radius: var(--nv-radius-xl); +} diff --git a/niveus/continuity.css b/niveus/continuity.css index 8c5e485..98fde02 100644 --- a/niveus/continuity.css +++ b/niveus/continuity.css @@ -200,6 +200,33 @@ view-transition-class: nv-content; } +/* --- Reveal ------------------------------------------------------------------------ + Content arriving as it scrolls into view: it rises a short way and fades in, + once, so a long page is read in the order it was written instead of all at + once. .nv-reveal-group does the same for its children one after another, + --nv-stagger apart. + + niveus.js marks what is waiting. Without the script, and under reduced + motion, nothing is ever marked — so nothing is ever hidden. */ + +.nv-reveal[data-nv-reveal="pending"], +.nv-reveal-group > [data-nv-reveal="pending"] { + opacity: 0; +} + +.nv-reveal[data-nv-reveal="shown"], +.nv-reveal-group > [data-nv-reveal="shown"] { + animation: nv-arrive var(--nv-duration-slower) var(--nv-ease-entrance) + calc(var(--nv-reveal-index, 0) * var(--nv-stagger)) both; +} + +@keyframes nv-arrive { + from { + opacity: 0; + translate: 0 var(--nv-travel-lg); + } +} + /* Collapsible region for markup that is not a
    . The 0fr -> 1fr grid row is the one technique that animates to an unknown content height in every browser, with no measurement and no script. */ diff --git a/niveus/niveus.css b/niveus/niveus.css index 6e9c852..3f198b2 100644 --- a/niveus/niveus.css +++ b/niveus/niveus.css @@ -58,6 +58,12 @@ @import url("components/badge.css") layer(niveus.components); @import url("components/table.css") layer(niveus.components); @import url("components/window.css") layer(niveus.components); +@import url("components/tile.css") layer(niveus.components); +@import url("components/avatar.css") layer(niveus.components); +@import url("components/stat.css") layer(niveus.components); +@import url("components/steps.css") layer(niveus.components); +@import url("components/deck.css") layer(niveus.components); +@import url("components/skeleton.css") layer(niveus.components); /* --- Utilities ----------------------------------------------------------------- */ @import url("utilities.css") layer(niveus.utilities); diff --git a/niveus/niveus.js b/niveus/niveus.js index a777030..144187e 100644 --- a/niveus/niveus.js +++ b/niveus/niveus.js @@ -27,6 +27,9 @@ [data-nv-close] button that closes its dialog or popover [data-nv-scroller] frame whose [data-nv-scroll] controls move its .nv-scroller a page at a time + .nv-reveal / .nv-reveal-group content that arrives as it scrolls into + view, once (see continuity.css) + .nv-card--spotlight card whose light follows the pointer Only what changes is lifted. A transition names the selection marker and the items of the groups it is given as `scope`, and only for as long as it @@ -297,6 +300,61 @@ return named; } + /** + * Reveal content as it scrolls into view. Elements are only marked as + * waiting here, so a page without this script — or under reduced motion — + * never hides anything. Each element arrives once and is then left alone. + */ + var revealer = null; + + function arrive(target) { + var items = target.classList.contains("nv-reveal-group") ? target.children : [target]; + for (var i = 0; i < items.length; i++) { + var item = items[i]; + item.style.setProperty("--nv-reveal-index", String(i)); + item.dataset.nvReveal = "shown"; + item.addEventListener("animationend", function settle(event) { + if (event.target !== this || event.animationName !== "nv-arrive") return; + this.removeEventListener("animationend", settle); + delete this.dataset.nvReveal; + this.style.removeProperty("--nv-reveal-index"); + }); + } + } + + function initReveal(scope) { + if (prefersReducedMotion() || typeof global.IntersectionObserver !== "function") return; + + revealer = revealer || new IntersectionObserver(function (entries) { + entries.forEach(function (entry) { + if (!entry.isIntersecting) return; + revealer.unobserve(entry.target); + arrive(entry.target); + }); + }, { rootMargin: "0px 0px -8% 0px" }); + + scope.querySelectorAll(".nv-reveal, .nv-reveal-group").forEach(function (target) { + if (target.dataset.nvRevealWired) return; + target.dataset.nvRevealWired = ""; + var items = target.classList.contains("nv-reveal-group") ? target.children : [target]; + for (var i = 0; i < items.length; i++) items[i].dataset.nvReveal = "pending"; + revealer.observe(target); + }); + } + + /** + * The light on a .nv-card--spotlight sits under the pointer. One listener + * for the whole document, so cards added later need no wiring. + */ + function trackSpotlight(event) { + if (event.pointerType && event.pointerType !== "mouse") return; + var surface = event.target.closest && event.target.closest(".nv-card--spotlight"); + if (!surface) return; + var box = surface.getBoundingClientRect(); + surface.style.setProperty("--nv-pointer-x", event.clientX - box.left + "px"); + surface.style.setProperty("--nv-pointer-y", event.clientY - box.top + "px"); + } + function wire(scope) { scope = scope || document; @@ -314,6 +372,7 @@ scope.querySelectorAll("[data-nv-tabs]").forEach(initTabs); scope.querySelectorAll("[data-nv-scroller]").forEach(initScroller); + initReveal(scope); scope.querySelectorAll("[data-nv-close]").forEach(function (control) { control.addEventListener("click", function () { @@ -345,6 +404,8 @@ prefersReducedMotion: prefersReducedMotion, }; + document.addEventListener("pointermove", trackSpotlight, { passive: true }); + if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", function () { wire(); }); } else { diff --git a/niveus/themes/dark.css b/niveus/themes/dark.css index 971c1d1..0c41e85 100644 --- a/niveus/themes/dark.css +++ b/niveus/themes/dark.css @@ -128,6 +128,7 @@ --nv-dark-sheen-bottom: rgb(0 0 0 / 0.18); --nv-dark-sheen-strong-top: rgb(255 255 255 / 0.3); --nv-dark-sheen-strong-bottom: rgb(0 0 0 / 0.2); + --nv-dark-glow: oklch(from var(--nv-dark-accent) l c h / 0.5); --nv-dark-knob: #f4f3ef; --nv-dark-knob-shade: rgb(40 32 20 / 0.3); diff --git a/niveus/themes/light.css b/niveus/themes/light.css index bba9304..357cf13 100644 --- a/niveus/themes/light.css +++ b/niveus/themes/light.css @@ -151,6 +151,7 @@ --nv-light-sheen-bottom: rgb(40 32 20 / 0.07); --nv-light-sheen-strong-top: rgb(255 255 255 / 0.32); --nv-light-sheen-strong-bottom: rgb(20 16 10 / 0.16); + --nv-light-glow: oklch(from var(--nv-light-accent) l c h / 0.3); --nv-light-knob: var(--nv-palette-neutral-0); --nv-light-knob-shade: rgb(40 32 20 / 0.13); diff --git a/niveus/tokens/color.css b/niveus/tokens/color.css index be1d7f5..9d35ca8 100644 --- a/niveus/tokens/color.css +++ b/niveus/tokens/color.css @@ -227,6 +227,11 @@ --nv-color-sheen-strong-top: light-dark(var(--nv-light-sheen-strong-top), var(--nv-dark-sheen-strong-top)); --nv-color-sheen-strong-bottom: light-dark(var(--nv-light-sheen-strong-bottom), var(--nv-dark-sheen-strong-bottom)); + /* Glow: light a surface gives off, as opposed to the shade it casts. It is + the accent seen through air — strong enough in dark to read as a lit + edge, faint in light, where paper does not emit. */ + --nv-color-glow: light-dark(var(--nv-light-glow), var(--nv-dark-glow)); + /* A knob: the thumb of a switch or a slider. It stays light in both appearances, the way a physical knob does not change color with the room. */ --nv-color-knob: light-dark(var(--nv-light-knob), var(--nv-dark-knob)); diff --git a/niveus/tokens/elevation.css b/niveus/tokens/elevation.css index 310711a..d185b73 100644 --- a/niveus/tokens/elevation.css +++ b/niveus/tokens/elevation.css @@ -95,4 +95,30 @@ --nv-focus-ring: 0 0 0 1px var(--nv-color-focus-ring-contrast), 0 0 0 calc(1px + var(--nv-focus-ring-width)) var(--nv-color-focus-ring); + + /* --- Glow ------------------------------------------------------------------ + Light a surface emits rather than the shade it casts. Glow is for things + that are *on* — a live status, the selected item in a showcase, a lit key — + and it always sits beside an elevation, never instead of one: a glowing + surface still rests on something. + + The geometry is a scale like elevation. The color is the component's: a + tinted surface glows in its own tint, mixed down to --nv-glow-strength. */ + --nv-glow-strength: 40%; + --nv-glow-blur-sm: 12px; + --nv-glow-spread-sm: -2px; + --nv-glow-blur-md: 28px; + --nv-glow-spread-md: -6px; + --nv-glow-blur-lg: 64px; + --nv-glow-spread-lg: -12px; +} + +/* The accent glows are declared on every branded subtree as well as the root: + a var() inside a custom property resolves where the property is declared, so + a root-only declaration would glow in the root brand everywhere. */ +:root, +[data-nv-brand] { + --nv-glow-sm: 0 0 var(--nv-glow-blur-sm) var(--nv-glow-spread-sm) var(--nv-color-glow); + --nv-glow-md: 0 0 var(--nv-glow-blur-md) var(--nv-glow-spread-md) var(--nv-color-glow); + --nv-glow-lg: 0 0 var(--nv-glow-blur-lg) var(--nv-glow-spread-lg) var(--nv-color-glow); } diff --git a/niveus/tokens/motion.css b/niveus/tokens/motion.css index b9075d8..b271e56 100644 --- a/niveus/tokens/motion.css +++ b/niveus/tokens/motion.css @@ -82,6 +82,15 @@ --nv-travel-sm: 4px; --nv-travel-md: 8px; --nv-travel-lg: 16px; + + /* --- Sequence --------------------------------------------------------------- + The gap between siblings arriving one after another. Short enough that a + group reads as one movement, long enough that the eye sees an order. */ + --nv-stagger: 60ms; + + /* A loading shimmer is a slow, constant sweep — slow enough to read as + "waiting", never fast enough to read as an alert. */ + --nv-duration-shimmer: 1600ms; } /* Reduced motion is a system-wide contract, not a per-component opt-in: cut @@ -103,5 +112,6 @@ --nv-travel-sm: 0px; --nv-travel-md: 0px; --nv-travel-lg: 0px; + --nv-stagger: 0ms; } }