Eunomia v1.1.0
AllAngularReactNext.jsJavaScriptVue
EunomiaSalmonForestVioletOceanGoldFireCustom…
🇬🇧 English🇫🇷 Français

Animate

Animate wraps any content and reveals it with a fade or slide transition the first time it scrolls into the viewport. Turning the animation off renders the content immediately instead, with no transition at all.

Overview API Examples Accessibility
I fade in once I scroll into view.

When to use

Use it to draw attention to content as someone scrolls down a page, such as a feature card, a section heading, or a row of statistics. Keep it subtle and brief, since it's a polish detail and not something the page's usability should depend on. The wrapped content always stays present on the page regardless of its animation or viewport state, so it's safe to wrap content that matters. The animation only ever affects how that content first appears, never whether it's there at all.

Install & usage

Pick a framework in the toolbar above and these snippets adapt.

npm install @eunomia/elements
import "@eunomia/elements/animate.js";
<eun-animate once animation="faded-scroll-top" duration="800" delay="100">
  <div class="card">...</div>
</eun-animate>

Importing the file registers <eun-animate> as a custom element, with no further setup needed. It works with any framework, or none, since it's a standard web component.

Guidance

  • Don't stretch duration much past its 1000ms default, and keep --animate-distance modest: a reveal that travels too far or too slowly reads as sluggish rather than polished
  • Gate a non-none animation behind your own prefers-reduced-motion check when you control how the attribute is set (see the Accessibility tab). Its own reduced-motion fallback is a safety net, not a substitute
  • Set once for most reveals, and leave it off only for content genuinely meant to replay (e.g. a demo section a user might scroll past repeatedly)
  • Wrapping content a user needs immediately (above-the-fold hero text, a form), rather than reserving it for content that naturally appears as the page is scrolled into
  • Stacking many eun-animate instances with a long delay each in sequence: a page that keeps visibly "loading in" as you scroll reads as flaky, not refined
  • Relying on animation alone to convey that content changed state: it's a one-time entrance effect, not a status indicator

Live testing

Properties

Animate <eun-animate>

Attributes

NameTypeDefaultDescription
laptop-delaynumberundefinedOverrides `delay` below the laptop breakpoint (width < 1024px)
tablet-delaynumberundefinedOverrides `delay` (and `laptopDelay`, if also set) below the tablet breakpoint (width < 768px)
mobile-delaynumberundefinedOverrides `delay` (and `tabletDelay`, if also set) below the mobile breakpoint (width < 600px)
animationEunomiaAnimateAnimation'fade'Which transition to apply. None renders the content immediately, with no transition
durationnumber1000The transition duration, in milliseconds. Left unset, it follows --animate-duration
delaynumber0The transition delay, in milliseconds
oncebooleanfalseWhether the animation plays only the first time it enters the viewport, or replays every time it re-enters
thresholdnumber0.1The ratio of the element, from 0 to 1, that must be visible before the animation triggers
root-marginstring'0px'The margin applied around the viewport before threshold is evaluated, accepting CSS-like values such as -50px 0px
laptop-animationEunomiaAnimateAnimationundefinedOverrides `animation` below the laptop breakpoint (width < 1024px). Left unset, the viewport keeps using `animation`
tablet-animationEunomiaAnimateAnimationundefinedOverrides `animation` (and `laptopAnimation`, if also set) below the tablet breakpoint (width < 768px)
mobile-animationEunomiaAnimateAnimationundefinedOverrides `animation` (and `tabletAnimation`, if also set) below the mobile breakpoint (width < 600px)

Import the exact TypeScript type behind any property above, see Types.

Slots

NameDescription
(default)The content to animate on scroll. Always present in the DOM and readable by assistive technology, regardless of viewport intersection

CSS custom properties

NameDescription
--animate-distanceSets the travel distance for the scroll and faded-scroll animations
--animate-durationSets the transition duration of every animate that has no duration of its own, for example once for the whole site

Animation

Animation types

animation accepts none, fade (default), scroll-right, scroll-left, scroll-top, scroll-bottom, faded-scroll-right, faded-scroll-left, faded-scroll-top, or faded-scroll-bottom. The scroll-* values translate in from a direction, and the faded-scroll-* values combine that with a fade. animation="none" skips the transition entirely (see "No animation" below).

↕ Scroll inside each column below (not the page): every card only animates in once it actually enters that scrollable area, and replays every time since once isn't set here (see the note below the demo).

Scroll: plain translate, no fade

scroll-right
scroll-left
scroll-top
scroll-bottom

Fade: opacity, alone or combined with a translate

fade
faded-scroll-right
faded-scroll-left
faded-scroll-top
faded-scroll-bottom
<eun-animate once animation="faded-scroll-top">
  <div class="card">faded-scroll-top</div>
</eun-animate>

Duration and delay

duration and delay are both in milliseconds, 1000 and 0 by default, and set the timing of the underlying CSS transition.

Slower, and delayed by 200ms.
<eun-animate once animation="faded-scroll-top" duration="1200" delay="200">
  <div class="card">...</div>
</eun-animate>

To change the duration of every animate at once, set --animate-duration instead, for example on :root. An instance with its own duration keeps it.

:root {
  --animate-duration: 800ms;
}

Custom distance

--animate-distance (default 80px) controls how far the scroll-*/ faded-scroll-* variants travel.

A shorter, subtler travel distance.
eun-animate.subtle {
  --animate-distance: 24px;
}

No animation

Setting animation to none renders the content immediately, with no transition, and skips setting up the observer entirely. It's useful as a single toggle to disable the animation conditionally, for example behind a prefers-reduced-motion check (see the Accessibility tab), without needing to remove animate from the markup at all. An instance switched to none mid-transition shows its content at once.

No animation, shown immediately.
<eun-animate once animation="none">
  <div class="card">...</div>
</eun-animate>
// Typical conditional gating, driven from your own application code:
const prefersReducedMotion = window.matchMedia(
  "(prefers-reduced-motion: reduce)",
).matches;
element.animation = prefersReducedMotion ? "none" : "faded-scroll-top";

Triggering

Playing only once

By default, the animation resets and replays every time the element re-enters the viewport. Scroll the demo below out of view and back to see it replay. Set once for it to play only the first time and stop observing, as most demos on this page do.

I replay every time I re-enter the viewport.
<eun-animate animation="scroll-left" once>
  <div class="card">...</div>
</eun-animate>

Threshold and root margin

threshold (default 0.1) is the ratio of the element that must be visible before the animation triggers. rootMargin (default 0px) shifts the viewport's own trigger boundary instead, accepting the same CSS-like values as IntersectionObserver itself (e.g. -50px 0px to require the element further past the edge before it's considered visible).

<!-- Triggers as soon as a single pixel is visible -->
<eun-animate once threshold="0">...</eun-animate>

<!-- Requires the element fully visible before triggering -->
<eun-animate once threshold="1">...</eun-animate>

<!-- Triggers only once 100px past the viewport's bottom edge -->
<eun-animate once root-margin="0px 0px -100px 0px">...</eun-animate>

Responsive animation

laptop-animation, tablet-animation and mobile-animation override animation at narrower viewports, one instance, no JavaScript, and no extra DOM. All three are pure CSS @media switches, below the library's laptop breakpoint (width < 1024px) for laptop-animation, below tablet (width < 768px) for tablet-animation and below mobile (width < 600px) for mobile-animation, so they're active from first paint, including in a server-rendered page that links animate-ssr.css (see Server-side rendering above): unlike a matchMedia listener, there's no JS to run before the right variant applies, so no post-hydration flip. Leave any of them unset to keep using the wider one at that width; the narrowest matching breakpoint wins, so mobile-animation wins over tablet-animation, which wins over laptop-animation.

Resize the window, then scroll me out and back into view.
<eun-animate
  once
  animation="faded-scroll-left"
  laptop-animation="faded-scroll-top"
  tablet-animation="scroll-top"
  mobile-animation="fade"
>
  <div class="card">...</div>
</eun-animate>

laptop-delay, tablet-delay and mobile-delay do the same for delay, at the same widths. A stagger between cards in a grid, for example, can drop once they're stacked:

<eun-animate once animation="fade" delay="200" tablet-delay="0">
  <div class="card">...</div>
</eun-animate>

These are the library's own laptop, tablet and mobile breakpoints, the same ones as the eunomia-below-tablet mixin, the grid classes and eun-toolbar's default mobile-breakpoint, so a section switches its layout and its animation at the same width. Need a breakpoint of your own, or one driven by something other than viewport width? See the techniques below instead.

A different breakpoint

laptop-animation, tablet-animation and mobile-animation switch at the library's fixed thresholds. For breakpoints of your own, fall back to one of these, depending on whether you'd rather stay in plain HTML/CSS or drive it from JavaScript.

HTML/CSS only, no JavaScript

Render one instance per breakpoint, each with its own animation, and let a plain @media query show only the one that matches. It's the same technique as any "hide on mobile" utility class, with nothing animate-specific about it. The trade-off is that every breakpoint's instance exists in the DOM at once, each with its own observer, so keep this to a handful of call sites rather than the whole page.

Resize the window: I'm the mobile variant (fade).
Resize the window: I'm the tablet variant (scroll-top).
Resize the window: I'm the desktop variant (faded-scroll-left).
<eun-animate once class="only-mobile" animation="fade">
  <div class="card">...</div>
</eun-animate>
<eun-animate once class="only-tablet" animation="scroll-top">
  <div class="card">...</div>
</eun-animate>
<eun-animate once class="only-desktop" animation="faded-scroll-left">
  <div class="card">...</div>
</eun-animate>
.only-mobile,
.only-tablet,
.only-desktop {
  display: none !important;
}
@media (width < 768px) {
  .only-mobile {
    display: block !important;
  }
}
@media (width >= 768px) and (width < 1024px) {
  .only-tablet {
    display: block !important;
  }
}
@media (width >= 1024px) {
  .only-desktop {
    display: block !important;
  }
}

The !important guarantees the show/hide rule always wins regardless of how specific your own page styles are. Drop it once you've confirmed your own cascade doesn't need it. The widths are the library's breakpoints, which the eunomia-below-* and eunomia-from-* Sass mixins write for you.

With JavaScript, a single instance

For a single instance, with no duplicated content or observers, animation is a plain reactive property, so switch it at runtime instead with your own matchMedia listener, the same technique eun-toolbar uses internally for its own mobile state. Being reactive, changing animation after the element is already connected rebuilds the observer immediately, so the switch takes effect right away, including for content that has already animated in once. Resetting once to false beforehand, as shown below, is only needed if you also want it to be able to replay after the switch.

Try it below by resizing your browser window (or opening dev tools' device toolbar), then scrolling the card out of view and back in.

Resize the window, then scroll me out and back into view.
import { mediaBelow, mediaFrom } from "@eunomia/elements/breakpoints.js";

const el = document.querySelector("eun-animate");
const mobileQuery = window.matchMedia(mediaBelow("tablet"));
const tabletQuery = window.matchMedia(
  `${mediaFrom("tablet")} and ${mediaBelow("laptop")}`,
);

function applyAnimation() {
  if (mobileQuery.matches) {
    el.animation = "fade"; // subtle on small screens
  } else if (tabletQuery.matches) {
    el.animation = "scroll-top";
  } else {
    el.animation = "faded-scroll-left"; // fuller effect on desktop
  }
}

mobileQuery.addEventListener("change", applyAnimation);
tabletQuery.addEventListener("change", applyAnimation);
applyAnimation(); // run once on load

mediaBelow and mediaFrom return the same conditions as the Sass mixins, from the library's breakpoints, so the script switches at the exact width your CSS does. The @eunomia/elements/breakpoints.js subpath registers no component, so it's safe to import on a server.

With a container query instead of the viewport

laptop-animation, tablet-animation and mobile-animation switch on the viewport's own width. For an instance whose relevant width is an ancestor's, a sidebar panel, a resizable split view, a card in a grid column, that's often the wrong dimension to key off of. A container query fixes that, entirely from your own page's CSS, no component changes needed: <eun-animate> is a plain element from the outside, stylable like any other, so your stylesheet can target it directly once an ancestor establishes the containment context.

The one caveat: the component's own :host([animation="…"]) rule lives in its Shadow DOM stylesheet, and at equal specificity, a rule declared inside a shadow tree wins over one from the outer page. Add !important to your override so it wins regardless, the same reason the multi-instance technique above already reaches for it on display. The in-view attribute itself is untouched by any of this, still toggled by the component's own IntersectionObserver exactly as usual, so leave animation set to a real value (not none) for that to keep running.

.card-grid-item {
  container-type: inline-size;
}

@container (width < 600px) {
  eun-animate.card-animate {
    opacity: 0 !important;
    transform: translateY(20px) !important;
    transition-property: opacity, transform !important;
  }
  eun-animate.card-animate[in-view] {
    opacity: 1 !important;
    transform: translateY(0) !important;
  }
}
<div class="card-grid-item">
  <eun-animate once class="card-animate" animation="fade">
    <div class="card">...</div>
  </eun-animate>
</div>

Without an ancestor's container-type declared somewhere, the @container rule simply never matches, and the instance quietly keeps using its own animation everywhere: safe to ship even before every consumer's layout has a container in place.

Server-side rendering

The hidden starting state (opacity: 0, or a translated position) lives in animate's own Shadow DOM, so it only applies once the browser upgrades the custom element. Server-rendered HTML has no Shadow DOM yet, so the wrapped content is visible on first paint, then snaps hidden the instant the element upgrades, then fades back in. That's a real flash, not just a cosmetic detail, since it also delays when above-the-fold content counts toward LCP.

Link animate-ssr.css in the page's <head> to fix this. It mirrors animate's own hidden starting state in a plain stylesheet that the browser applies from first paint, using :not(:defined) so it stops matching, and hands off cleanly to animate's own styles, the moment the element upgrades:

<link
  rel="stylesheet"
  href="node_modules/@eunomia/elements/dist/ssr/animate-ssr.css"
/>

A bundler that resolves package exports (Vite, webpack, Turbopack, or a Sass @use/@import) can reach the same file through the package specifier instead, which survives a hoisted or monorepo install the raw path above doesn't:

@use "@eunomia/elements/animate-ssr.css";

It covers every animation, including the default fade when the animation attribute is left out. Content further down the page, already off-screen at first paint, doesn't need it since there's no flash to see yet.

The hidden starting state only applies while scripting is enabled (@media (scripting: enabled)). With JavaScript off, the element never upgrades, so the content shows in place without a transition instead of staying hidden for good.

Global transition blockers

A common anti-FOUC pattern disables every transition on the page until load, with a rule such as html.no-transitions * { transition: none !important; }. Animate's entrance is a transition on its own host, and in-view is often set before load for content above the fold, so that rule swallows the entrance and the content simply appears. Exclude animate from the blocker so its entrance still plays:

html.no-transitions *:not(eun-animate) {
  transition: none !important;
}

Reduced motion

eun-animate's own transition automatically collapses to near-instant (0.01ms) under prefers-reduced-motion: reduce, the same fallback pattern used throughout this library (see eun-toolbar/eun-tooltip's own Accessibility tabs). Still, whenever your own application code decides which animation to set, gate anything beyond none behind that same media query directly (see "No animation" in the Examples tab) rather than relying on the CSS fallback alone. A user who's asked for less motion shouldn't have to trust a component-level safety net to honor it. This matches WCAG 2.3.3 Animation from Interactions.

Content is never gated behind the animation

threshold/rootMargin only control when the transition plays. The wrapped content is always present in the DOM and readable by assistive technology regardless of viewport intersection or animation state. A screen reader user, or anyone browsing with JavaScript-driven visual effects disabled, still gets the full content. Nothing is ever hidden from the accessibility tree by animate itself.

No semantics of its own

eun-animate carries no ARIA role and isn't a landmark or a focusable element. It's a purely visual wrapper, and it doesn't alter the semantics or focus order of whatever's slotted into it.

Reference links

WCAG 2.3.3: Animation from Interactions
MDN: prefers-reduced-motion
MDN: Intersection Observer API