Eunomia v1.0.0-beta.2
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 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

  • Keep duration short (the 600ms default is already on the generous side) and --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 to false sparingly, for content genuinely meant to replay (e.g. a demo section a user might scroll past repeatedly). Most uses want the default one-time reveal, and it must be set through the property, not a plain once="false" attribute (see "Replaying on every scroll" in the Examples tab)
  • 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
animationEunomiaAnimateAnimation'fade'Which transition to apply. None renders the content immediately, with no transition
durationnumber600The transition duration, in milliseconds
delaynumber0The transition delay, in milliseconds
oncebooleantrueWhether 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

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

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 is set to false 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

Every card above replays on each re-entry, set via el.once = false in a small script after the elements exist, not via a once="false" attribute in the markup itself, which wouldn't work (see "Replaying on every scroll" below for why).

<eun-animate animation="faded-scroll-top">
  <div class="card">faded-scroll-top</div>
</eun-animate>

Duration and delay

duration/delay are both in milliseconds (default 600/0), controlling the underlying CSS transition's own timing directly.

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

Replaying on every scroll

By default (once), the animation plays once and stops observing. Set once to false to have it reset and replay every time the element re-enters the viewport. Scroll the demo below out of view and back to see it replay.

once defaults to true, and like any boolean HTML attribute, its mere presence means true. Writing once="false" in plain markup still sets it to true. Turn it off through the property instead, using your framework's own boolean binding syntax, or element.once = false in plain JavaScript. See the snippets below for the exact syntax in each framework.
I replay every time I re-enter the viewport.
document.querySelector("eun-animate").once = false;

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 threshold="0">...</eun-animate>

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

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

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.

No animation, shown immediately.
<eun-animate 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";

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/components/layout/animate/animate-ssr.css"
/>

This only works for instances whose animation attribute is written explicitly in the server-rendered markup, including the default:

<eun-animate animation="fade">
  <div class="card">...</div>
</eun-animate>

animation's default value of "fade" is only ever reflected as an attribute once Lit initializes the property client-side, so an instance that omits it renders with no hidden starting state at all until then, which the stylesheet above can't see or fix. Reserve this stylesheet, and eun-animate above the fold in general, for content you're fine seeing appear without a transition for however long the JS bundle takes to load. Content further down the page, already off-screen at first paint, doesn't need it since there's no flash to see yet.

Responsive animation

There's no dedicated breakpoint prop on animate itself, so there are two ways to get a different animation per breakpoint, 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 class="only-mobile" animation="fade">
  <div class="card">...</div>
</eun-animate>
<eun-animate class="only-tablet" animation="scroll-top">
  <div class="card">...</div>
</eun-animate>
<eun-animate class="only-desktop" animation="faded-scroll-left">
  <div class="card">...</div>
</eun-animate>
.only-mobile,
.only-tablet,
.only-desktop {
  display: none !important;
}
@media (max-width: 767px) {
  .only-mobile {
    display: block !important;
  }
}
@media (min-width: 768px) and (max-width: 1023px) {
  .only-tablet {
    display: block !important;
  }
}
@media (min-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. Match the pixel values to your actual CSS breakpoints.

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.
const el = document.querySelector("eun-animate");
const mobileQuery = window.matchMedia("(max-width: 767px)");
const tabletQuery = window.matchMedia(
  "(min-width: 768px) and (max-width: 1023px)",
);

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

Either way, match your CSS breakpoints exactly rather than hardcoding pixel values a second time here. Read them from whatever shared constant or CSS custom property your app already defines, if any.

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;
}

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