Skip to content

Component

entrance

Demo

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

Source

Copy both files, or run npx nojsui add entrance.

Source for entrance
<!-- .sk-entrance is a utility, not a wrapper: put the class on the thing that
     should arrive. The stagger goes on the parent.

     Nothing here changes the accessibility tree. The animation only touches
     opacity, translate and scale, so the content is announced whether or not
     it has finished arriving — and under reduced motion it is simply here. -->
<div class="sk-entrance__hero" data-sk-entrance-stagger>
  <p class="sk-entrance__eyebrow sk-entrance" data-entrance="slide-up">Zero JavaScript</p>
  <h3 class="sk-entrance__title sk-entrance" data-entrance="slide-up">
    Arrives on load, with no runtime
  </h3>
  <p class="sk-entrance__text sk-entrance" data-entrance="slide-up">
    A view() timeline never fires for content that starts on screen, so a
    scroll reveal cannot animate a hero. This can, in about fifteen lines.
  </p>
  <p class="sk-entrance__text sk-entrance" data-entrance="fade">
    Each line begins one step after the one above it. Here the step is a
    duration, because there is no scroll timeline to conflict with — the
    opposite of how the scroll reveal has to do it.
  </p>
</div>
/* .sk-entrance — content arrives on load.

   WHY THIS EXISTS. A view() timeline measures an element's position in the
   scrollport, so it never fires for content that is already on screen when the
   page loads. That makes .sk-reveal structurally unable to animate a hero —
   and a hero arriving is the one moment a static site still loads an animation
   runtime for. This is that moment, in about fifteen lines.

   THE ASYMMETRY WITH .sk-reveal, which is confusing until it is explained:
   there animation-delay does nothing, because a scroll-driven animation has no
   wall clock and the stagger has to shift the range instead. Here there is no
   timeline, so animation-delay is exactly the right tool and the step is a
   duration. Same feature, opposite mechanism, for a real reason.

   Everything that hides lives in @keyframes inside the reduced-motion guard,
   so the base state is always the visible one.

   Specs:
   - animation-fill-mode ..... https://drafts.csswg.org/css-animations/#animation-fill-mode
   - animation-delay ......... https://drafts.csswg.org/css-animations/#animation-delay
   - sibling-index() ......... https://drafts.csswg.org/css-values-5/#sibling-functions */

/* Public theme knobs (ADR 0011). Read, never declared. Declared on the element
   itself: like .sk-reveal this is a utility applied directly to whatever it
   animates, so it has no root of its own. */
.sk-entrance {
  --_distance: var(--sk-entrance-distance, var(--sk-space-lg));
  --_scale-from: var(--sk-entrance-scale-from, 0.94);
  --_duration: var(--sk-entrance-duration, var(--sk-motion-slow));
  --_step: var(--sk-entrance-step, 100ms);

  /* 1 unless something supplies an index — see the @supports below. */
  --_i: var(--sk-index, 1);
}

@media (prefers-reduced-motion: no-preference) {
  /* No @supports around this: CSS animations are Baseline widely available,
     so unlike .sk-reveal there is no engine where the keyframes will not run.
     `both` holds the from-state through the delay, which is what makes a
     stagger look deliberate rather than like a slow page. */
  .sk-entrance {
    animation-name: sk-entrance-fade;
    animation-duration: var(--_duration);
    animation-timing-function: var(--sk-ease-out);
    animation-fill-mode: both;
  }

  .sk-entrance[data-entrance="slide-up"] {
    animation-name: sk-entrance-slide-up;
  }

  .sk-entrance[data-entrance="scale"] {
    animation-name: sk-entrance-scale;
  }

  @supports (animation-delay: calc(sibling-index() * 1ms)) {
    .sk-entrance {
      --_i: var(--sk-index, sibling-index());
    }
  }

  /* The stagger goes on the parent; children read their own index. Without
     sibling-index() and without --sk-index every child computes a delay of zero
     and the group arrives together — still animated, just not sequenced. */
  [data-sk-entrance-stagger] > .sk-entrance {
    animation-delay: calc((var(--_i) - 1) * var(--_step));
  }

  @keyframes sk-entrance-fade {
    from {
      opacity: 0;
    }
  }

  @keyframes sk-entrance-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-entrance-scale {
    from {
      opacity: 0;
      scale: var(--_scale-from);
    }
  }
}

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

   The hero below is the demo's dressing. Delete it when applying .sk-entrance
   to your own markup — the animation does not care what it is attached to.
--------------------------------------------------------------------------- */

.sk-entrance__hero {
  display: grid;
  gap: var(--sk-space-sm);
  padding: var(--sk-space-xl);
}

.sk-entrance__eyebrow {
  margin: 0;
  color: var(--sk-color-accent);
  font-size: var(--sk-text-xs);
  font-weight: var(--sk-weight-semibold);
  letter-spacing: var(--sk-tracking-wide);
  text-transform: uppercase;
}

.sk-entrance__title {
  font-size: var(--sk-text-2xl);
  letter-spacing: var(--sk-tracking-tight);
}

.sk-entrance__text {
  margin: 0;
  color: var(--sk-color-text-muted);
}

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
sibling-count() and sibling-index()limited13813826.213826.2

Usage

A view() timeline measures an element’s position in the scrollport, so it never fires for content that is already on screen when the page loads. That makes .sk-reveal structurally unable to animate a hero — and a hero arriving is the single most common reason a static marketing site still ships an animation runtime. .sk-entrance is that moment, on the ordinary CSS animation clock instead of a scroll timeline.

It is a utility, not a wrapper: put the class on the thing that should arrive.

<h1 class="sk-entrance">…</h1>
<h1 class="sk-entrance" data-entrance="slide-up">…</h1>
<h1 class="sk-entrance" data-entrance="scale">…</h1>
data-entranceEffect
(omitted)Fades in. Opacity only, nothing moves
slide-upFades in while rising by --sk-entrance-distance
scaleFades in while growing from --sk-entrance-scale-from

The hero look in the demo comes from three rules at the bottom of the stylesheet that have nothing to do with the entrance. Delete them when applying this to your own markup; the animation does not care what it is attached to.

The asymmetry with reveal

reveal’s stagger shifts animation-range-start, because a scroll-driven animation has no wall clock — animation-delay does nothing to it, so the stagger step has to move where in the range each child starts, and that range is expressed as a percentage.

entrance has no scroll timeline to conflict with, so animation-delay is exactly the right tool, and --sk-entrance-step is a duration, not a percentage. Same feature — later children arrive later — opposite mechanism, because the two components are staggering different kinds of progress: one a position in the scrollport, the other elapsed time. See reveal’s README for the stagger that has to fight a timeline with no clock; this is the one that doesn’t.

Staggering a group

Put data-sk-entrance-stagger on the parent; children pick up the sequencing automatically.

<div data-sk-entrance-stagger>
  <p class="sk-entrance" data-entrance="slide-up">…</p>
  <p class="sk-entrance" data-entrance="slide-up">…</p>
  <p class="sk-entrance" data-entrance="slide-up">…</p>
</div>

Sequencing comes from sibling-index(), supported in Chrome 138+ and Safari 26.2+. Without it — or in the window before it shipped — every child computes the same delay and the group arrives together, still animated, just not sequenced. 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 an entrance theme knob — it has no row in the table below and it is --sk-index, not --sk-entrance-index. A child’s position among its siblings is a fact about the markup, not about 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 reveal elsewhere in the kit.

The attribute carries the sk- prefix — data-sk-entrance-stagger, not data-entrance-stagger — because it lands on a parent that has no component class of its own; an unprefixed attribute there would be a bare global selector matching anything in a consumer’s page. data-sk-motion is the existing precedent for that shape.

Composing with a reveal exit

.sk-entrance and .sk-reveal[data-reveal-exit] do not combine on one element. animation-name is a single cascaded value: put both classes on the same node and only the stylesheet that happens to cascade second wins — the other’s animation vanishes with no error and no warning.

A hero that should both enter on load and exit on scroll needs nesting instead — an outer element for the scroll-driven exit, an inner one for the on-load entrance:

<div class="sk-reveal" data-reveal-exit="slide-up">
  <div class="sk-entrance" data-entrance="slide-up">…</div>
</div>

reveal’s README documents this same recipe from the other side.

How it works

@media (prefers-reduced-motion: no-preference) {
  .sk-entrance {
    animation-name: sk-entrance-fade;
    animation-duration: var(--_duration);
    animation-timing-function: var(--sk-ease-out);
    animation-fill-mode: both;
  }
  @keyframes sk-entrance-fade { from { opacity: 0; } }
}

No @supports guards the animation itself: CSS animations are Baseline widely available, so unlike reveal there is no engine where these keyframes fail to run. animation-fill-mode: both holds the from-state through the delay, which is what makes a stagger read as deliberate rather than as a slow page.

Everything that hides an element lives inside @keyframes, which are only ever attached inside the reduced-motion guard — the base state, outside that guard, is always the visible one.

Theming

Set any of these anywhere above the component — :root, a section wrapper, or one instance. entrance.css only ever reads them, so the nearest declaration wins (ADR 0011).

PropertyDefaultControls
--sk-entrance-distance--sk-space-lgHow far slide-up travels
--sk-entrance-scale-from0.94Where scale starts
--sk-entrance-duration--sk-motion-slowHow long one element takes
--sk-entrance-step100msHow much later each child begins

Keyboard contract

None. There is nothing to operate — this is decoration over static content, not an interactive control.

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 entering content is in the accessibility tree from first paint, whether or not the animation has finished.

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

The floor here is empty: CSS animations are Baseline widely available, so the entrance itself always runs, in every engine this kit tests. sibling-count is the only enhancement — without it, a staggered group arrives together instead of in sequence.

Feature usedBaseline statusBehavior without it
sibling-countlimitedEvery child in a staggered group computes the same delay and arrives together — still animated, just not sequenced