Component
reveal
Demo
tokens.css,
base.css and reveal.css. No scripts.
Source
Copy both files, or run npx nojsui add reveal.
Browser support
Works across current and earlier versions of every major engine.
| Feature | Baseline | Chrome | Edge | Firefox | Safari | Chrome Android | Firefox Android | Safari iOS |
|---|
| Feature | Baseline | Chrome | Edge | Firefox | Safari | Chrome Android | Firefox Android | Safari iOS |
|---|---|---|---|---|---|---|---|---|
| Scroll-driven animations | limited | 115 | 115 | — | 26 | 115 | — | 26 |
| sibling-count() and sibling-index() | limited | 138 | 138 | — | 26.2 | 138 | — | 26.2 |
Usage
Reveal-on-scroll with no observer, no scroll listener and no script. A view()
timeline ties the animation’s progress to where the element sits in the
scrollport — the browser is already tracking that, and the animation just reads
it.
It is a utility, not a wrapper. Put the class on the thing you want revealed.
<section class="sk-reveal">…</section>
<section class="sk-reveal" data-reveal="slide-up">…</section>
<section class="sk-reveal" data-reveal="scale">…</section>
data-reveal | Effect |
|---|---|
| (omitted) | Fades in. Opacity only, nothing moves |
slide-up | Fades in while rising by --sk-reveal-distance |
scale | Fades in while growing from --sk-reveal-scale-from |
The card look in the demo comes from three rules at the bottom of the stylesheet that have nothing to do with the reveal. Delete them when applying this to your own markup; the animation does not care what it is attached to.
The one thing that matters
Content is never hidden by a feature that might not be there.
Every declaration that reduces opacity or moves an element lives inside
@keyframes, and those keyframes are only attached inside both
@supports (animation-timeline: view()) and
@media (prefers-reduced-motion: no-preference). There is no opacity: 0 at
the top level of the stylesheet and there must never be one.
That is not a hypothetical. Firefox 153 does not support view() timelines
(measured 2026-08-20), so a third of the engines this kit tests render the
fallback. Written the usual way — a base opacity: 0 “revealed” by an
animation — this component would leave every section it touches invisible in
Firefox, present in the DOM, present in the accessibility tree, and blank on
screen. It is the single most common way this pattern ships broken.
The spec suite asserts it directly, with motion allowed and reduced — but the
guarantee it can actually enforce is not uniform across engines. The check
excludes any element with a genuinely running scroll-driven animation (an
element mid-view()-range is supposed to be below full opacity; that is
the component working, not a bug), and Chromium and WebKit both attach that
animation to every .sk-reveal in this demo. Firefox cannot: it has no
view() support at all, so nothing is ever excluded there and the suite’s
teeth against a stranded .sk-reveal — one left below full opacity by a
missing or broken feature, forever, not mid-animation — are sharpest in
exactly the engine that lacks the feature. That is not a coincidence: it is
the engine where this component’s one failure mode actually manifests, and
where the check needs to see it.
Staggering a group
Put data-sk-reveal-stagger on the parent; children pick up the sequencing
automatically.
<div data-sk-reveal-stagger>
<section class="sk-reveal" data-reveal="slide-up">…</section>
<section class="sk-reveal" data-reveal="slide-up">…</section>
<section class="sk-reveal" data-reveal="slide-up">…</section>
</div>
animation-delay does nothing on a scroll timeline. There is no wall
clock; progress is the element’s position in the scrollport, not time — this
is the wall every consumer hits before deciding scroll-driven CSS is broken.
That is why the stagger step is a percentage of the range rather than a
duration. Contrast this with the entrance motion elsewhere in the kit, which
is time-based and where animation-delay is the right tool — the asymmetry
is confusing until you know one timeline has a clock and the other doesn’t.
Sequencing comes from sibling-index(), supported in Chrome 138+ and Safari
26.2+. view() itself shipped earlier — Chrome 115 and Safari 26.0 — so there
is a real window (Chrome 115–137, Safari 26.0–26.1) where the reveal animates
but every child computes the same offset and the group moves together instead
of in sequence. A consumer who needs sequencing in that window can set
--sk-index per child; treat it as an escape hatch for a closing window, not the
primary API.
--sk-index is not a reveal theme knob — it has no row in the table
below and it is --sk-index, not --sk-reveal-index. A child’s position
among its siblings is a property of the markup, not of this component, so
the property is page-level and unprefixed, the same shape as --sk-density
(ADR 0019). See ADR 0030 for why, and for the same property read by
entrance elsewhere in the kit.
The stagger’s base is the un-staggered default (entry 10%), not
--sk-reveal-range-start: animation-range-start needs a range name plus an
addable percentage, and that knob holds both as one opaque value. Override it
while also staggering and you get the stagger’s own base, not yours.
Exiting
data-reveal-exit fades a band out as it leaves the scrollport, on the
exit range (--sk-reveal-exit-range-start / --sk-reveal-exit-range-end).
<section class="sk-reveal" data-reveal-exit>…</section>
<section class="sk-reveal" data-reveal-exit="slide-up">…</section>
<section class="sk-reveal" data-reveal-exit="scale">…</section>
data-reveal-exit | Effect |
|---|---|
| (no value) | Fades out. Opacity only, nothing moves |
slide-up | Fades out while rising by --sk-reveal-exit-distance |
scale | Fades out while shrinking to --sk-reveal-scale-from |
An entrance and an exit coexist on one element — data-reveal="slide-up" data-reveal-exit="slide-up" rises in and fades out — because one stylesheet
emits both as a two-item animation-name list, one item set by
data-reveal, the other by data-reveal-exit.
Two components could not do this. animation-name is a single cascaded
value: .sk-entrance and .sk-reveal[data-reveal-exit] on the same element
would leave only whichever stylesheet’s rule loaded second — silently, with
no error, no warning, and the other’s animation simply never runs. Combining
entrance with a reveal exit means nesting them on separate elements — the
outer one carries the scroll-driven exit, the inner one the on-load entrance:
<div class="sk-reveal" data-reveal-exit="slide-up">
<div class="sk-entrance" data-entrance="slide-up">…</div>
</div>
entrance’s README
covers the same ground from the other side.
As with the entrance, all opacity change lives inside @keyframes, guarded
by the same @supports and @media pair described above. An engine without
the timeline — Firefox today — leaves the band visible; there is nothing to
fade out of, because there was never anything to fade in.
How it works
@media (prefers-reduced-motion: no-preference) {
@supports (animation-timeline: view()) {
.sk-reveal {
/* A two-item list, not one: an entrance and an exit, each with its own
name and range. `none` is a valid, inert animation-name, so an
element with no exit variant still runs exactly one real
animation. */
animation-name: sk-reveal-fade, none;
animation-duration: auto, auto;
animation-fill-mode: both, both;
animation-timeline: view(), view();
animation-range: entry 10% entry 70%, exit 0% exit 100%;
}
@keyframes sk-reveal-fade { from { opacity: 0; } }
}
}
Three details are load-bearing, and each one was found by measuring rather than by reading:
- Longhands, not the
animationshorthand. The shorthand resetsanimation-durationto0s, and a scroll-driven animation with a zero duration finishes instantly: every element renders at its end state and the reveal silently does nothing.autois what hands the timing to the timeline. With the shorthand, all four demo blocks sit at full opacity in Chromium before any scrolling. view()binds to the nearest ancestor scroll container. Wrap the elements in anoverflow: autobox that does not happen to overflow and the timeline is inactive — nothing animates, in either supporting engine. The demo’s stack is deliberately a plain flex column so the page is the scrollport, which is what a consumer has anyway.animation-fill-mode: bothis what holds an element at the start of its range while it is still below the fold. It is safe only inside the@supportsblock, because it is only reachable in engines that will actually run the timeline.
The keyframes have a from and no to. The implicit to is the element’s own
style, so the utility never has to know what it is revealing.
Theming
Set any of these anywhere above the component — :root, a section wrapper, or
one instance. reveal.css only ever reads them, so the nearest declaration wins
(ADR 0011).
| Property | Default | Controls |
|---|---|---|
--sk-reveal-distance | --sk-space-lg | How far slide-up travels |
--sk-reveal-scale-from | 0.94 | Where scale starts, and where an exiting scale ends |
--sk-reveal-exit-distance | --sk-space-lg | How far slide-up travels on the way out |
--sk-reveal-range-start | entry 10% | Where in the scrollport the reveal begins |
--sk-reveal-range-end | entry 70% | Where it completes |
--sk-reveal-exit-range-start | exit 0% | Where an exit begins |
--sk-reveal-exit-range-end | exit 100% | Where an exit completes |
--sk-reveal-stagger-step | 8% | How much later each child begins, as a share of the reveal’s range |
--sk-reveal-card-bg | --sk-color-surface | Demo card background |
--sk-reveal-card-fg | --sk-color-text | Demo card text |
--sk-reveal-card-border-color | --sk-color-border | Demo card border |
--sk-reveal-card-radius | --sk-radius-lg | Demo card corner radius |
--sk-reveal-text-fg | --sk-color-text-muted | Demo card prose |
:root { --sk-reveal-distance: 3rem; }
.slow-page { --sk-reveal-range-end: entry 100%; }
The range knobs are the interesting ones: entry 10% entry 70% means the
reveal starts when the element is a tenth of the way into the scrollport and
finishes at seven tenths, so it settles before it reaches the middle of the
screen rather than while the reader is looking at it.
Keyboard contract
None. Nothing here is focusable or interactive — it is a presentational utility, and it adds no tab stops.
| Key | Behaviour |
|---|---|
| (any) | Nothing. The utility adds no interaction and no focus targets |
Verified manually in VoiceOver and NVDA: not yet — do this before marking the component done, and change this line when you have.
Accessibility notes
The animation only ever touches opacity, translate and scale. It never
touches display, visibility, content-visibility or the element’s presence
in the DOM, so revealed content is in the accessibility tree from the first
paint, whether or not it has been scrolled to. A screen reader reading straight
down the page, or a find-in-page hit landing below the fold, both work.
That is the difference between this and a scripted reveal that adds content on intersection: there is nothing to wait for, because nothing was ever removed.
Under prefers-reduced-motion: reduce the animation is not merely shortened —
it is never attached, so there is no residual movement to notice. Motion is the
entire feature, so reducing it means not having it.
Degradation
| Feature used | Baseline status | Behavior without it |
|---|---|---|
scroll-driven-animations | limited | Every element is visible from the start, exactly as if the class were absent. Firefox 153 today. This is the fallback, and it is a complete experience — the reveal is decoration on content that was always there |