Add depth and light: glow, tiles, avatars, stats, steps, decks, skeletons, reveal

Tokens
- --nv-color-glow role (light and dark) and a glow scale: --nv-glow-sm/md/lg,
  --nv-glow-blur-*, --nv-glow-spread-*, --nv-glow-strength
- --nv-stagger (0ms under reduced motion) and --nv-duration-shimmer

Components
- Card: --lit (lit top edge in --nv-card-tint), --glow, --spotlight
  (light follows the pointer); light layers combine with every elevation
- Tile: small raised key for a glyph or product mark; --solid, --glow, sizes
- Avatar: raised disc with --ring / --glow, sizes, __status, avatar group
- Stat: readings set into one recessed well (.nv-stat-group)
- Steps: markers set into a rail; done steps light their stretch; vertical
- Deck: one surface in front of stacked layers of its kind
- Skeleton: recessed placeholder with a slow sweep, still under reduced motion
- Text: .nv-text-lit and .nv-text-lit--accent

Continuity
- .nv-reveal / .nv-reveal-group: content arrives once as it scrolls into view
  (nv-arrive keyframe); niveus.js marks pending state, so nothing is hidden
  without the script or under reduced motion
- niveus.js tracks the pointer for .nv-card--spotlight

README and REFERENCE document every new class, token and attribute;
tools/check.py passes and dist/ is rebuilt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Nexoniarz 2026-10-18 22:59:25 +02:00
commit 7908c55245
19 changed files with 1619 additions and 14 deletions

View file

@ -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 Applied as classes: `.nv-material-frosted`, `.nv-elevation-3`. A shadow that is
on neither scale is a shadow that means nothing. 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 ## Continuity
Every interaction should read as a continuation of the state before it. When 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]` `[data-nv-theme]`, `[data-nv-dialog]`, `[data-nv-tabs]` and `[data-nv-close]`
so the common cases need no code at all. 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 ### One rule worth knowing
A `view-transition-name` must be unique among all rendered elements, or the 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-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-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 `<dialog>`; `--sheet` `--compact` `--wide` | | `.nv-dialog` | native `<dialog>`; `--sheet` `--compact` `--wide` |
| `.nv-nav` | `--vertical` `--plain` `--inline`, plus `.nv-nav-tabs`, `.nv-tab-panel` and `.nv-breadcrumbs` | | `.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` | | `.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-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-badge` | state, not an action; `--accent` `--success` `--warning` `--danger`, `--solid` `--count` `--square` |
| `.nv-table` | inside `.nv-table-frame`; `__number` `__actions`, `--compact` `--interactive` | | `.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 | | `.nv-surface` | materials and elevation as utilities |
Variants are defined by reassigning private `--_*` properties. Interaction — Variants are defined by reassigning private `--_*` properties. Interaction —

View file

@ -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--frosted` | glass card | Translucent, for use over imagery or a bed. |
| `.nv-card--interactive` | clickable card | Lifts on hover, sinks on press. | | `.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--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__header` | card header | |
| `.nv-card__title` | card title | | | `.nv-card__title` | card title | |
| `.nv-card__subtitle` | card subtitle, supporting text | | | `.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__link` | stretched link | Expands one link's hit area over the whole card. |
| `.nv-card-grid` | responsive card grid | Auto-fitting, equal-height columns. | | `.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 ## Dialog
Built on native `<dialog>`; open with `showModal()` or `nv.openDialog()`. Built on native `<dialog>`; 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--square` | square badge | |
| `.nv-badge__dot` | status dot | | | `.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 `<img>` inside fills the key, an `<svg>`
takes half of it.
## Avatar
| Class | Known as | Does |
| --- | --- | --- |
| `.nv-avatar` | avatar, profile picture | Round raised disc holding an `<img>` 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 `<dl>`. |
| `.nv-stat` | stat, metric, KPI | One reading: label, value, optional delta, in that order. |
| `.nv-stat__label` | metric label | `<dt>` inside a group. |
| `.nv-stat__value` | metric value, big number | `<dd>`; 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 `<ol>`. |
| `.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 ## Table
| Class | Known as | Does | | Class | Known as | Does |
@ -256,6 +327,15 @@ var(--nv-sheen-raised)` over its role color.
| `.nv-code` | code, monospace | 13px | | `.nv-code` | code, monospace | 13px |
| `.nv-text-primary` `.nv-text-secondary` `.nv-text-tertiary` | text color roles | Emphasis, not size. | | `.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-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 ## Layout utilities
@ -309,6 +389,7 @@ material-frosted material-glass material-border
window-close window-minimize window-zoom window-inactive window-close window-minimize window-zoom window-inactive
window-control-ring window-glyph window-control-ring window-glyph
focus-ring focus-ring-contrast selection focus-ring focus-ring-contrast selection
glow
``` ```
`on-*` is the text that goes on top of that color, and is derived — never set `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-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-blur-` | `sm md lg xl` — also `backdrop-frosted` `backdrop-glass` `backdrop-scrim` |
| `--nv-material-` | `{solid,elevated,inset,frosted,glass}-{bg,border,shadow}` | | `--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 ### Motion
| Family | Steps | | 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-ease-` | `standard entrance exit spring emphasized linear` |
| `--nv-transition-` | `colors depth control surface expand` (composites) | | `--nv-transition-` | `colors depth control surface expand` (composites) |
| displacement | `--nv-lift-hover` `--nv-press-depth` `--nv-travel-sm/md/lg` | | 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 `prefers-reduced-motion` collapses every duration and displacement at the token
level. Components never opt in individually. 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-border-width-strong` · `--nv-target-min` · `--nv-layout-gutter` ·
`--nv-layout-max-width` · `--nv-selection-name` · `--nv-anchor` · `--nv-layout-max-width` · `--nv-selection-name` · `--nv-anchor` ·
`--nv-button-offset` · `--nv-bed-tint` · `--nv-scroller-item` · `--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.selectTab` | `(tablist, tab) => Promise` | Moves selection and its panel, with direction. |
| `nv.initScroller` | `(frame) => void` | Wires one scroller's controls. | | `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.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` | | | `nv.prefersReducedMotion` | `() => boolean` | |
### Data attributes ### 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-scrolled` | `.nv-nav` | Set by the application on scroll; gives the bar its shadow. |
| `data-nv-direction` | `<html>` | Set during a transition by `nv.transition`. | | `data-nv-direction` | `<html>` | Set during a transition by `nv.transition`. |
| `data-nv-transition` | `<html>` | `"theme"` during an appearance change. | | `data-nv-transition` | `<html>` | `"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. | | `data-nv-travelling` | selection group | Set by `nv.transition` on each `scope` for the length of the change; names its marker. |
--- ---

726
dist/niveus.css vendored
View file

@ -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-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)); --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 /* 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. */ 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)); --nv-color-knob: light-dark(var(--nv-light-knob), var(--nv-dark-knob));
@ -598,6 +603,32 @@
--nv-focus-ring: --nv-focus-ring:
0 0 0 1px var(--nv-color-focus-ring-contrast), 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); 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 { @layer niveus.tokens {
@ -685,6 +716,15 @@
--nv-travel-sm: 4px; --nv-travel-sm: 4px;
--nv-travel-md: 8px; --nv-travel-md: 8px;
--nv-travel-lg: 16px; --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 /* Reduced motion is a system-wide contract, not a per-component opt-in: cut
@ -706,6 +746,7 @@
--nv-travel-sm: 0px; --nv-travel-sm: 0px;
--nv-travel-md: 0px; --nv-travel-md: 0px;
--nv-travel-lg: 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-bottom: rgb(40 32 20 / 0.07);
--nv-light-sheen-strong-top: rgb(255 255 255 / 0.32); --nv-light-sheen-strong-top: rgb(255 255 255 / 0.32);
--nv-light-sheen-strong-bottom: rgb(20 16 10 / 0.16); --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: var(--nv-palette-neutral-0);
--nv-light-knob-shade: rgb(40 32 20 / 0.13); --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-bottom: rgb(0 0 0 / 0.18);
--nv-dark-sheen-strong-top: rgb(255 255 255 / 0.3); --nv-dark-sheen-strong-top: rgb(255 255 255 / 0.3);
--nv-dark-sheen-strong-bottom: rgb(0 0 0 / 0.2); --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: #f4f3ef;
--nv-dark-knob-shade: rgb(40 32 20 / 0.3); --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-success { color: var(--nv-color-success-text); }
.nv-text-warning { color: var(--nv-color-warning-text); } .nv-text-warning { color: var(--nv-color-warning-text); }
.nv-text-danger { color: var(--nv-color-danger-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 ----------------------------- */ /* --- Continuity: how one state becomes the next ----------------------------- */
@ -1570,6 +1642,33 @@
view-transition-class: nv-content; 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 <details>. The 0fr -> 1fr grid /* Collapsible region for markup that is not a <details>. The 0fr -> 1fr grid
row is the one technique that animates to an unknown content height in every row is the one technique that animates to an unknown content height in every
browser, with no measurement and no script. */ browser, with no measurement and no script. */
@ -2473,6 +2572,9 @@
.nv-card--inset recessed into its parent .nv-card--inset recessed into its parent
.nv-card--frosted over imagery or scrolling content .nv-card--frosted over imagery or scrolling content
.nv-card--interactive the whole card is one target .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 .nv-card__header / __body / __footer / __media
A card is a container, not a look. If a card needs a shadow that is not on 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); --_shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1);
--_sheen: var(--nv-sheen-surface); --_sheen: var(--nv-sheen-surface);
--_padding: var(--nv-space-5); --_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; position: relative;
display: flex; display: flex;
@ -2497,11 +2604,11 @@
min-inline-size: 0; min-inline-size: 0;
background-color: var(--_bg); background-color: var(--_bg);
background-image: var(--_sheen); background-image: var(--_spot), var(--_sheen);
color: var(--nv-color-text-primary); color: var(--nv-color-text-primary);
border: var(--nv-border-width) solid var(--_border); border: var(--nv-border-width) solid var(--_border);
border-radius: var(--nv-radius-lg); border-radius: var(--nv-radius-lg);
box-shadow: var(--_shadow); box-shadow: var(--_lit), var(--_shadow), var(--_glow);
backdrop-filter: var(--nv-backdrop-glass); backdrop-filter: var(--nv-backdrop-glass);
-webkit-backdrop-filter: var(--nv-backdrop-glass); -webkit-backdrop-filter: var(--nv-backdrop-glass);
@ -2553,20 +2660,75 @@
@media (hover: hover) { @media (hover: hover) {
.nv-card--interactive: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); translate: 0 calc(var(--nv-lift-hover) * 2);
} }
} }
.nv-card--interactive:active { .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); translate: 0 var(--nv-press-depth);
transition-duration: var(--nv-duration-instant); transition-duration: var(--nv-duration-instant);
} }
.nv-card--interactive:focus-visible { .nv-card--interactive:focus-visible {
outline: none; 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 /* Cards whose whole area is clickable via a link inside: the link's target is
@ -4164,6 +4326,560 @@
overflow: auto; overflow: auto;
} }
} }
@layer niveus.components {
/* ==========================================================================
Niveus — Tile
--------------------------------------------------------------------------
<span class="nv-tile"><svg …></svg></span>
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
--------------------------------------------------------------------------
<span class="nv-avatar"><img src="…" alt=""></span>
<span class="nv-avatar">NX</span>
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
--------------------------------------------------------------------------
<dl class="nv-stat-group">
<div class="nv-stat">
<dt class="nv-stat__label">Servers</dt>
<dd class="nv-stat__value">1,204</dd>
</div>
</dl>
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
--------------------------------------------------------------------------
<ol class="nv-steps">
<li class="nv-steps__item" data-state="done">
<span class="nv-steps__marker">1</span>
<div class="nv-steps__body">
<h3 class="nv-steps__title">Listen</h3>
<p class="nv-steps__text">…</p>
</div>
</li>
</ol>
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
--------------------------------------------------------------------------
<div class="nv-deck">
<article class="nv-card">…</article>
<div class="nv-deck__layer" aria-hidden="true"></div>
<div class="nv-deck__layer" aria-hidden="true"></div>
</div>
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
--------------------------------------------------------------------------
<span class="nv-skeleton nv-skeleton--text"></span>
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 ----------------------------------------------------------------- */ /* --- Utilities ----------------------------------------------------------------- */
@layer niveus.utilities { @layer niveus.utilities {

View file

@ -204,3 +204,32 @@ textarea {
.nv-text-success { color: var(--nv-color-success-text); } .nv-text-success { color: var(--nv-color-success-text); }
.nv-text-warning { color: var(--nv-color-warning-text); } .nv-text-warning { color: var(--nv-color-warning-text); }
.nv-text-danger { color: var(--nv-color-danger-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)
);
}

View file

@ -0,0 +1,113 @@
/* ==========================================================================
Niveus — Avatar
--------------------------------------------------------------------------
<span class="nv-avatar"><img src="…" alt=""></span>
<span class="nv-avatar">NX</span>
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);
}

View file

@ -6,6 +6,9 @@
.nv-card--inset recessed into its parent .nv-card--inset recessed into its parent
.nv-card--frosted over imagery or scrolling content .nv-card--frosted over imagery or scrolling content
.nv-card--interactive the whole card is one target .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 .nv-card__header / __body / __footer / __media
A card is a container, not a look. If a card needs a shadow that is not on 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); --_shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1);
--_sheen: var(--nv-sheen-surface); --_sheen: var(--nv-sheen-surface);
--_padding: var(--nv-space-5); --_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; position: relative;
display: flex; display: flex;
@ -30,11 +38,11 @@
min-inline-size: 0; min-inline-size: 0;
background-color: var(--_bg); background-color: var(--_bg);
background-image: var(--_sheen); background-image: var(--_spot), var(--_sheen);
color: var(--nv-color-text-primary); color: var(--nv-color-text-primary);
border: var(--nv-border-width) solid var(--_border); border: var(--nv-border-width) solid var(--_border);
border-radius: var(--nv-radius-lg); border-radius: var(--nv-radius-lg);
box-shadow: var(--_shadow); box-shadow: var(--_lit), var(--_shadow), var(--_glow);
backdrop-filter: var(--nv-backdrop-glass); backdrop-filter: var(--nv-backdrop-glass);
-webkit-backdrop-filter: var(--nv-backdrop-glass); -webkit-backdrop-filter: var(--nv-backdrop-glass);
@ -86,20 +94,75 @@
@media (hover: hover) { @media (hover: hover) {
.nv-card--interactive: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); translate: 0 calc(var(--nv-lift-hover) * 2);
} }
} }
.nv-card--interactive:active { .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); translate: 0 var(--nv-press-depth);
transition-duration: var(--nv-duration-instant); transition-duration: var(--nv-duration-instant);
} }
.nv-card--interactive:focus-visible { .nv-card--interactive:focus-visible {
outline: none; 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 /* Cards whose whole area is clickable via a link inside: the link's target is

View file

@ -0,0 +1,55 @@
/* ==========================================================================
Niveus — Deck
--------------------------------------------------------------------------
<div class="nv-deck">
<article class="nv-card">…</article>
<div class="nv-deck__layer" aria-hidden="true"></div>
<div class="nv-deck__layer" aria-hidden="true"></div>
</div>
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;
}

60
niveus/components/skeleton.css vendored Normal file
View file

@ -0,0 +1,60 @@
/* ==========================================================================
Niveus — Skeleton
--------------------------------------------------------------------------
<span class="nv-skeleton nv-skeleton--text"></span>
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;
}
}

View file

@ -0,0 +1,74 @@
/* ==========================================================================
Niveus — Stat
--------------------------------------------------------------------------
<dl class="nv-stat-group">
<div class="nv-stat">
<dt class="nv-stat__label">Servers</dt>
<dd class="nv-stat__value">1,204</dd>
</div>
</dl>
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);
}

157
niveus/components/steps.css Normal file
View file

@ -0,0 +1,157 @@
/* ==========================================================================
Niveus — Steps
--------------------------------------------------------------------------
<ol class="nv-steps">
<li class="nv-steps__item" data-state="done">
<span class="nv-steps__marker">1</span>
<div class="nv-steps__body">
<h3 class="nv-steps__title">Listen</h3>
<p class="nv-steps__text">…</p>
</div>
</li>
</ol>
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));
}
}

View file

@ -0,0 +1,83 @@
/* ==========================================================================
Niveus — Tile
--------------------------------------------------------------------------
<span class="nv-tile"><svg …></svg></span>
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);
}

View file

@ -200,6 +200,33 @@
view-transition-class: nv-content; 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 <details>. The 0fr -> 1fr grid /* Collapsible region for markup that is not a <details>. The 0fr -> 1fr grid
row is the one technique that animates to an unknown content height in every row is the one technique that animates to an unknown content height in every
browser, with no measurement and no script. */ browser, with no measurement and no script. */

View file

@ -58,6 +58,12 @@
@import url("components/badge.css") layer(niveus.components); @import url("components/badge.css") layer(niveus.components);
@import url("components/table.css") layer(niveus.components); @import url("components/table.css") layer(niveus.components);
@import url("components/window.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 ----------------------------------------------------------------- */ /* --- Utilities ----------------------------------------------------------------- */
@import url("utilities.css") layer(niveus.utilities); @import url("utilities.css") layer(niveus.utilities);

View file

@ -27,6 +27,9 @@
[data-nv-close] button that closes its dialog or popover [data-nv-close] button that closes its dialog or popover
[data-nv-scroller] frame whose [data-nv-scroll] controls move [data-nv-scroller] frame whose [data-nv-scroll] controls move
its .nv-scroller a page at a time 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 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 the items of the groups it is given as `scope`, and only for as long as it
@ -297,6 +300,61 @@
return named; 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) { function wire(scope) {
scope = scope || document; scope = scope || document;
@ -314,6 +372,7 @@
scope.querySelectorAll("[data-nv-tabs]").forEach(initTabs); scope.querySelectorAll("[data-nv-tabs]").forEach(initTabs);
scope.querySelectorAll("[data-nv-scroller]").forEach(initScroller); scope.querySelectorAll("[data-nv-scroller]").forEach(initScroller);
initReveal(scope);
scope.querySelectorAll("[data-nv-close]").forEach(function (control) { scope.querySelectorAll("[data-nv-close]").forEach(function (control) {
control.addEventListener("click", function () { control.addEventListener("click", function () {
@ -345,6 +404,8 @@
prefersReducedMotion: prefersReducedMotion, prefersReducedMotion: prefersReducedMotion,
}; };
document.addEventListener("pointermove", trackSpotlight, { passive: true });
if (document.readyState === "loading") { if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", function () { wire(); }); document.addEventListener("DOMContentLoaded", function () { wire(); });
} else { } else {

View file

@ -128,6 +128,7 @@
--nv-dark-sheen-bottom: rgb(0 0 0 / 0.18); --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-top: rgb(255 255 255 / 0.3);
--nv-dark-sheen-strong-bottom: rgb(0 0 0 / 0.2); --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: #f4f3ef;
--nv-dark-knob-shade: rgb(40 32 20 / 0.3); --nv-dark-knob-shade: rgb(40 32 20 / 0.3);

View file

@ -151,6 +151,7 @@
--nv-light-sheen-bottom: rgb(40 32 20 / 0.07); --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-top: rgb(255 255 255 / 0.32);
--nv-light-sheen-strong-bottom: rgb(20 16 10 / 0.16); --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: var(--nv-palette-neutral-0);
--nv-light-knob-shade: rgb(40 32 20 / 0.13); --nv-light-knob-shade: rgb(40 32 20 / 0.13);

View file

@ -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-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)); --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 /* 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. */ 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)); --nv-color-knob: light-dark(var(--nv-light-knob), var(--nv-dark-knob));

View file

@ -95,4 +95,30 @@
--nv-focus-ring: --nv-focus-ring:
0 0 0 1px var(--nv-color-focus-ring-contrast), 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); 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);
} }

View file

@ -82,6 +82,15 @@
--nv-travel-sm: 4px; --nv-travel-sm: 4px;
--nv-travel-md: 8px; --nv-travel-md: 8px;
--nv-travel-lg: 16px; --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 /* Reduced motion is a system-wide contract, not a per-component opt-in: cut
@ -103,5 +112,6 @@
--nv-travel-sm: 0px; --nv-travel-sm: 0px;
--nv-travel-md: 0px; --nv-travel-md: 0px;
--nv-travel-lg: 0px; --nv-travel-lg: 0px;
--nv-stagger: 0ms;
} }
} }