Skip to content

Component

reveal

Demo

Live demo, isolated in an iframe with only tokens.css, base.css and reveal.css. No scripts.

Source

Copy both files, or run npx nojsui add reveal.

Source for reveal
<!-- .sk-reveal is a utility, not a wrapper. Put the class on the thing you want
     revealed; there is no container element and nothing to nest.

     Nothing here is interactive and nothing changes the accessibility tree:
     the animation only ever touches opacity, translate and scale, so the
     content is announced whether or not it has been scrolled to. A reveal that
     removed content from the tree until it was reached would be a
     screen-reader trap, and this pattern must never become one.

     .sk-reveal__stack is only a flex column for the demo's spacing. It is
     deliberately NOT a scroll container: view() binds to the nearest ancestor
     scroll container, so wrapping these in an `overflow: auto` box that does
     not happen to overflow leaves the timeline inactive and nothing animates
     at all. The scrollport is the page — which is what a consumer has anyway,
     so the utility needs no wrapper of its own. -->
<div class="sk-reveal__stack">
  <p class="sk-reveal__hint">Scroll down ↓</p>

  <section class="sk-reveal" data-reveal="slide-up">
    <h3 class="sk-reveal__title">Slide up</h3>
    <p class="sk-reveal__text">Rises into place as it enters the scrollport.</p>
  </section>

  <section class="sk-reveal">
    <h3 class="sk-reveal__title">Fade</h3>
    <p class="sk-reveal__text">The default. Opacity only, nothing moves.</p>
  </section>

  <section class="sk-reveal" data-reveal="scale">
    <h3 class="sk-reveal__title">Scale</h3>
    <p class="sk-reveal__text">Settles up to full size from slightly under it.</p>
  </section>

  <section class="sk-reveal" data-reveal="slide-up">
    <h3 class="sk-reveal__title">And again</h3>
    <p class="sk-reveal__text">
      In Firefox, and under reduced motion, every one of these is simply here.
    </p>
  </section>

  <!-- Both at once: this rises in on the way up the scrollport and fades out
       on the way off it. One element, two animations, one stylesheet. -->
  <section class="sk-reveal" data-reveal="slide-up" data-reveal-exit="slide-up">
    <h3 class="sk-reveal__title">In, then out</h3>
    <p class="sk-reveal__text">
      An entrance and an exit on one element. Two components could not do
      this — animation-name is a single value, so whichever stylesheet
      loaded second would win and the other would silently vanish.
    </p>
  </section>

  <!-- The stagger goes on the PARENT. Each child starts later in the range —
       not later in time, which would do nothing on a scroll timeline. -->
  <div class="sk-reveal__group" data-sk-reveal-stagger>
    <section class="sk-reveal" data-reveal="slide-up">
      <h3 class="sk-reveal__title">First</h3>
      <p class="sk-reveal__text">Begins at the start of the range.</p>
    </section>
    <section class="sk-reveal" data-reveal="slide-up">
      <h3 class="sk-reveal__title">Second</h3>
      <p class="sk-reveal__text">One step later.</p>
    </section>
    <section class="sk-reveal" data-reveal="slide-up">
      <h3 class="sk-reveal__title">Third</h3>
      <p class="sk-reveal__text">Two steps later. In Firefox all three are simply here.</p>
    </section>
  </div>
</div>
/* .sk-reveal — reveal-on-scroll, driven by the scroll position itself.

   A view() timeline ties an animation's progress to where the element sits in
   the scrollport, so there is no observer, no scroll listener and no script.
   The browser is already tracking this; the animation just reads it.

   THE RULE THIS FILE EXISTS TO OBEY: 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 ever attached
   inside BOTH `@supports (animation-timeline: view())` and
   `@media (prefers-reduced-motion: no-preference)`. There is no `opacity: 0`
   at the top level of this file, and there must never be one — that is the
   classic way this pattern fails, and it fails silently: the content is in the
   DOM, in the accessibility tree, and invisible.

   Firefox 153 does not support view() timelines (measured 2026-08-20), so this
   is not a hypothetical fallback — it is what a third of the engines this kit
   tests actually render.

   Specs:
   - animation-timeline ..... https://drafts.csswg.org/scroll-animations-1/#animation-timeline
   - view() ................. https://drafts.csswg.org/scroll-animations-1/#view-notation
   - animation-range ........ https://drafts.csswg.org/scroll-animations-1/#animation-range */

/* Public theme knobs (ADR 0011). Read, never declared, so the nearest
   declaration above the component wins.

   These are declared on the element itself rather than a wrapper: a utility is
   applied directly to whatever it reveals, so it has no root of its own. */
.sk-reveal {
  --_distance: var(--sk-reveal-distance, var(--sk-space-lg));
  --_card-bg: var(--sk-reveal-card-bg, var(--sk-color-surface));
  --_card-fg: var(--sk-reveal-card-fg, var(--sk-color-text));
  --_card-border: var(--sk-reveal-card-border-color, var(--sk-color-border));
  --_card-radius: var(--sk-reveal-card-radius, var(--sk-radius-lg));
  --_text-fg: var(--sk-reveal-text-fg, var(--sk-color-text-muted));
  --_scale-from: var(--sk-reveal-scale-from, 0.94);
  --_range-start: var(--sk-reveal-range-start, entry 10%);
  --_range-end: var(--sk-reveal-range-end, entry 70%);
  --_exit-range-start: var(--sk-reveal-exit-range-start, exit 0%);
  --_exit-range-end: var(--sk-reveal-exit-range-end, exit 100%);
  --_exit-distance: var(--sk-reveal-exit-distance, var(--sk-space-lg));

  /* The card look is demo dressing, folded in here so the utility has a single
     rule rather than a second .sk-reveal further down. */
  padding: var(--sk-space-md);
  border: var(--sk-border-width) solid var(--_card-border);
  border-radius: var(--_card-radius);
  background: var(--_card-bg);
  color: var(--_card-fg);
}

@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: view()) {
    /* Longhands, not the `animation` shorthand: the shorthand resets
       animation-duration to 0s, and a zero-duration scroll-driven animation
       finishes instantly — every element renders at its end state and the
       reveal silently does nothing. `auto` hands timing to the timeline.
       `both` holds the element at the start of its range while still below
       the fold — safe only here, where the timeline is guaranteed to run.

       The NAME is a custom property, not a literal, so this rule carries a
       two-item list: an entrance and an exit, each with its own range. A
       literal name per variant would need one rule per combination — nine,
       once the exit variants below are added. `none` is a valid, inert
       animation-name, so an element with no exit still runs exactly one
       animation. */
    .sk-reveal {
      --_enter-name: sk-reveal-fade;
      --_exit-name: none;

      animation-name: var(--_enter-name), var(--_exit-name);
      animation-duration: auto, auto;
      animation-timing-function: linear, linear;
      animation-fill-mode: both, both;
      animation-timeline: view(), view();
      animation-range: var(--_range-start) var(--_range-end), var(--_exit-range-start) var(--_exit-range-end);

      /* THE STAGGER, AND WHY IT IS NOT animation-delay: a scroll-driven
         animation has no wall clock — progress is position, not time — so
         animation-delay does nothing, the wall every consumer hits before
         deciding scroll-driven CSS is broken. Sequencing siblings means
         starting each one LATER IN THE RANGE, hence a percentage step, not
         a duration.

         --_i is 1 unless supplied: the @supports below hands it
         sibling-index(), a consumer can override with --sk-index per child, and
         with neither every child offsets by zero and the group just
         animates together — it still runs, it just does not sequence.

         --sk-index, not --sk-reveal-index: a child's index among its
         siblings is a property of the markup, not of this component, and
         `entrance` reads the same property for the same purpose. It is a
         page-level property (ADR 0030), not a `--sk-reveal-*` theme knob, so
         it has no row in the Theming table below and check-theme-api.mjs
         does not see it.
         https://drafts.csswg.org/css-values-5/#sibling-functions */
      --_i: var(--sk-index, 1);
      --_stagger-step: var(--sk-reveal-stagger-step, 8%);
    }

    .sk-reveal[data-reveal="slide-up"] {
      --_enter-name: sk-reveal-slide-up;
    }

    .sk-reveal[data-reveal="scale"] {
      --_enter-name: sk-reveal-scale;
    }

    @supports (animation-range-start: entry calc(1% + sibling-index() * 1%)) {
      .sk-reveal {
        --_i: var(--sk-index, sibling-index());
      }
    }

    /* Only the entrance's START moves — its end stays put, so a late child
       gets a shorter range and catches up rather than overrunning the
       scrollport. `entry 10%` repeats --_range-start's default rather than
       reading it: animation-range-start needs a name plus an addable
       percentage, and --sk-reveal-range-start holds both as one opaque
       value. A consumer who overrides that knob and also staggers gets the
       stagger's own base, not theirs — see the README.

       TWO ITEMS, MATCHING animation-name's LIST LENGTH, NOT ONE: CSS repeats
       a short coordinating list (animation-range-start included) up to
       animation-name's length, so a one-item value here would not just set
       the entrance's start — it would repeat onto the exit animation too,
       silently overwriting --_exit-range-start with the stagger's own
       start for every staggered child. That was invisible while
       --_exit-name was `none`, because a `none` animation has nothing to
       clobber; it stops being invisible the moment a real exit variant is
       added, and reads as a mystery bug in the group that already went
       through review here. Write the exit's own start explicitly so it
       survives the stagger. */
    [data-sk-reveal-stagger] > .sk-reveal {
      animation-range-start: entry calc(10% + (var(--_i) - 1) * var(--_stagger-step)), var(--_exit-range-start);
    }

    /* The exit. The two-item animation-name list above means these set one
       property rather than a whole animation, so an element can carry an
       entrance and an exit at once without a rule per combination. */
    .sk-reveal[data-reveal-exit] {
      --_exit-name: sk-reveal-exit-fade;
    }

    .sk-reveal[data-reveal-exit="slide-up"] {
      --_exit-name: sk-reveal-exit-slide-up;
    }

    .sk-reveal[data-reveal-exit="scale"] {
      --_exit-name: sk-reveal-exit-scale;
    }

    /* An element already on screen when the page loads is past its range, so
       it renders at the end state — no flash, nothing to wait for. */
    @keyframes sk-reveal-fade {
      from {
        opacity: 0;
      }
    }

    @keyframes sk-reveal-slide-up {
      from {
        opacity: 0;

        /* Logical: translate is physical, so the offset is written against the
           block axis and flips with the writing mode. */
        translate: 0 var(--_distance);
      }
    }

    @keyframes sk-reveal-scale {
      from {
        opacity: 0;
        scale: var(--_scale-from);
      }
    }

    @keyframes sk-reveal-exit-fade {
      to {
        opacity: 0;
      }
    }

    @keyframes sk-reveal-exit-slide-up {
      to {
        opacity: 0;
        translate: 0 calc(-1 * var(--_exit-distance));
      }
    }

    @keyframes sk-reveal-exit-scale {
      to {
        opacity: 0;
        scale: var(--_scale-from);
      }
    }
  }
}

/* ---------------------------------------------------------------------------
   Presentation, not behaviour

   Everything below dresses the demo. The animation above is the whole
   component and it does not care what it is attached to — delete these rules
   when applying .sk-reveal to your own markup.
--------------------------------------------------------------------------- */

.sk-reveal__title {
  margin-block-end: var(--sk-space-2xs);
  font-size: var(--sk-text-md);
  letter-spacing: var(--sk-tracking-tight);
}

.sk-reveal__text {
  margin: 0;
  color: var(--_text-fg);
  font-size: var(--sk-text-sm);
}

/* Just a stack. Deliberately NOT a scroll container: `view()` binds to the
   nearest ancestor scroll container, so an `overflow: auto` wrapper that
   happens not to overflow becomes an inactive timeline and the reveal silently
   never runs — measured, and the reason this is a plain flex column. The
   scrollport is the page, which is what a consumer has anyway. */
.sk-reveal__stack {
  display: flex;
  flex-direction: column;
  gap: var(--sk-space-2xl);
  padding-block-end: var(--sk-space-3xl);
}

.sk-reveal__hint {
  margin: 0;
  color: var(--sk-color-text-subtle);
  font-size: var(--sk-text-xs);
}

.sk-reveal__group {
  display: grid;

  /* auto-fit, not a fixed repeat(3, 1fr): the fixed form crushed each column
     to nothing at a narrow viewport instead of reflowing to fewer of them.
     No media query needed — the docs suite tests pages at 320px, and this
     wraps to a single column there on its own. Same house style as card.css. */
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr));
  gap: var(--sk-space-md);
}

Browser support

Baseline widely available

Works across current and earlier versions of every major engine.

Per-feature support, generated from web-features 3.35.0
FeatureBaselineChromeEdgeFirefoxSafariChrome AndroidFirefox AndroidSafari iOS
Enhancements — the component works without these; they add polish. Their status does not affect the badge above.
FeatureBaselineChromeEdgeFirefoxSafariChrome AndroidFirefox AndroidSafari iOS
Scroll-driven animationslimited1151152611526
sibling-count() and sibling-index()limited13813826.213826.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-revealEffect
(omitted)Fades in. Opacity only, nothing moves
slide-upFades in while rising by --sk-reveal-distance
scaleFades 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-exitEffect
(no value)Fades out. Opacity only, nothing moves
slide-upFades out while rising by --sk-reveal-exit-distance
scaleFades 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 animation shorthand. The shorthand resets animation-duration to 0s, and a scroll-driven animation with a zero duration finishes instantly: every element renders at its end state and the reveal silently does nothing. auto is 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 an overflow: auto box 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: both is what holds an element at the start of its range while it is still below the fold. It is safe only inside the @supports block, 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).

PropertyDefaultControls
--sk-reveal-distance--sk-space-lgHow far slide-up travels
--sk-reveal-scale-from0.94Where scale starts, and where an exiting scale ends
--sk-reveal-exit-distance--sk-space-lgHow far slide-up travels on the way out
--sk-reveal-range-startentry 10%Where in the scrollport the reveal begins
--sk-reveal-range-endentry 70%Where it completes
--sk-reveal-exit-range-startexit 0%Where an exit begins
--sk-reveal-exit-range-endexit 100%Where an exit completes
--sk-reveal-stagger-step8%How much later each child begins, as a share of the reveal’s range
--sk-reveal-card-bg--sk-color-surfaceDemo card background
--sk-reveal-card-fg--sk-color-textDemo card text
--sk-reveal-card-border-color--sk-color-borderDemo card border
--sk-reveal-card-radius--sk-radius-lgDemo card corner radius
--sk-reveal-text-fg--sk-color-text-mutedDemo 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.

KeyBehaviour
(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 usedBaseline statusBehavior without it
scroll-driven-animationslimitedEvery 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