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 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.
npm install @eunomia/elements
<script type="module">
import "@eunomia/elements/animate.js";
</script>
<eun-animate once animation="faded-scroll-top">
<div class="card">...</div>
</eun-animate>
npm install @eunomia/elements
import "@eunomia/elements/animate.js";
function FeatureCard() {
return (
<eun-animate once 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 once 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 once 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 once animation="faded-scroll-top">
<div class="card">...</div>
</eun-animate>
`,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class FeatureCardComponent {}
Guidance
- Don't stretch
durationmuch past its 1000ms default, and keep--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
oncefor 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-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 |
|---|---|---|---|
| laptop-delay | number | undefined | Overrides `delay` below the laptop breakpoint (width < 1024px) |
| tablet-delay | number | undefined | Overrides `delay` (and `laptopDelay`, if also set) below the tablet breakpoint (width < 768px) |
| mobile-delay | number | undefined | Overrides `delay` (and `tabletDelay`, if also set) below the mobile breakpoint (width < 600px) |
| animation | EunomiaAnimateAnimation | 'fade' | Which transition to apply. None renders the content immediately, with no transition |
| duration | number | 1000 | The transition duration, in milliseconds. Left unset, it follows --animate-duration |
| delay | number | 0 | The transition delay, in milliseconds |
| once | boolean | false | 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 |
| laptop-animation | EunomiaAnimateAnimation | undefined | Overrides `animation` below the laptop breakpoint (width < 1024px). Left unset, the viewport keeps using `animation` |
| tablet-animation | EunomiaAnimateAnimation | undefined | Overrides `animation` (and `laptopAnimation`, if also set) below the tablet breakpoint (width < 768px) |
| mobile-animation | EunomiaAnimateAnimation | undefined | Overrides `animation` (and `tabletAnimation`, if also set) below the mobile breakpoint (width < 600px) |
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 |
| --animate-duration | Sets 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
Fade: opacity, alone or combined with a translate
<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.
<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.
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.
<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.
<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.
<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 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.
fade).
scroll-top).
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.
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
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
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