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.
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.
npm install @eunomia/elements
<script type="module">
import "@eunomia/elements/animate.js";
</script>
<eun-animate animation="faded-scroll-top">
<div class="card">...</div>
</eun-animate>
npm install @eunomia/elements
import "@eunomia/elements/animate.js";
function FeatureCard() {
return (
<eun-animate animation="faded-scroll-top">
<div className="card">...</div>
</eun-animate>
);
}
npm install @eunomia/elements
"use client";
import "@eunomia/elements/animate.js";
export function FeatureCard() {
return (
<eun-animate animation="faded-scroll-top">
<div className="card">...</div>
</eun-animate>
);
}
npm install @eunomia/elements
<script setup>
import "@eunomia/elements/animate.js";
</script>
<template>
<eun-animate animation="faded-scroll-top">
<div class="card">...</div>
</eun-animate>
</template>
npm install @eunomia/elements
import { CUSTOM_ELEMENTS_SCHEMA, Component } from "@angular/core";
import "@eunomia/elements/animate.js";
@Component({
selector: "app-feature-card",
template: `
<eun-animate animation="faded-scroll-top">
<div class="card">...</div>
</eun-animate>
`,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class FeatureCardComponent {}
Guidance
- Keep
durationshort (the 600ms default is already on the generous side) and--animate-distancemodest: a reveal that travels too far or too slowly reads as sluggish rather than polished - Gate a non-
noneanimationbehind your ownprefers-reduced-motioncheck 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
oncetofalsesparingly, 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 plainonce="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-animateinstances with a longdelayeach in sequence: a page that keeps visibly "loading in" as you scroll reads as flaky, not refined - Relying on
animationalone to convey that content changed state: it's a one-time entrance effect, not a status indicator
Live testing
Properties
Animate <eun-animate>
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
| animation | EunomiaAnimateAnimation | 'fade' | Which transition to apply. None renders the content immediately, with no transition |
| duration | number | 600 | The transition duration, in milliseconds |
| delay | number | 0 | The transition delay, in milliseconds |
| once | boolean | true | Whether the animation plays only the first time it enters the viewport, or replays every time it re-enters |
| threshold | number | 0.1 | The ratio of the element, from 0 to 1, that must be visible before the animation triggers |
| root-margin | string | '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
Slots
| Name | Description |
|---|---|
| (default) | The content to animate on scroll. Always present in the DOM and readable by assistive technology, regardless of viewport intersection |
CSS custom properties
| Name | Description |
|---|---|
| --animate-distance | Sets 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
Fade: opacity, alone or combined with a translate
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.
<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.
document.querySelector("eun-animate").once = false;
<eun-animate animation="scroll-left" once={false}>
<div className="card">...</div>
</eun-animate>
<template>
<eun-animate animation="scroll-left" :once="false">
<div class="card">...</div>
</eun-animate>
</template>
<eun-animate animation="scroll-left" [once]="false">
<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 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.
<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
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.
fade).
scroll-top).
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.
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.
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
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
prefers-reduced-motion