<Animate>
Fade, zoom or slide content in when it scrolls into view, with CSS animations driven by one shared IntersectionObserver.
import { Animate } from '@gio.js/react';
export default function Home() {
return (
<section>
<h1>Ship faster</h1>
<Animate enter="fade-up">
<p>Everything below the fold eases in as you scroll.</p>
</Animate>
</section>
);
}The animations are plain CSS keyframes; the component ships them in a <style> element that React hoists into the head once per page, so there is nothing to import.
Reference
| Prop | Type | Default | Description |
|---|---|---|---|
enter (required) | AnimatePreset | - | The entrance: 'fade-up', 'fade-down', 'fade-in', 'zoom-in', 'slide-right' or 'slide-left'. |
duration | number | 400 | Length of the animation, in milliseconds. |
delay | number | 0 | Wait before it starts, in milliseconds. Stagger a list with growing delays. |
when | 'visible' | 'immediate' | 'visible' | 'visible' starts when at least 10% of the element is in the viewport; 'immediate' starts as soon as the page has hydrated. |
className | string | - | Passed to the wrapping <div>. |
children (required) | React.ReactNode | - | The content to animate. |
The preset type is exported as AnimatePreset.
Presets
| Preset | From | To |
|---|---|---|
fade-up | transparent, 20px lower | opaque, in place |
fade-down | transparent, 20px higher | opaque, in place |
fade-in | transparent | opaque |
zoom-in | transparent, at 92% size | opaque, full size |
slide-right | transparent, 24px to the left | opaque, in place |
slide-left | transparent, 24px to the right | opaque, in place |
Behavior
<Animate> renders a <div data-gio-animate="<preset>"> with --gio-duration and --gio-delay set in its style. The stylesheet keeps such an element at opacity: 0 until it carries data-gio-animate-state="entered", then runs the preset's keyframes with ease timing and keeps the final frame. After hydration, the component sets that attribute right away (when="immediate") or hands the element to the shared observer ('visible'), which sets it the first time the element is 10% visible and then stops watching it: each element animates once. With prefers-reduced-motion: reduce the content is shown at once, with no animation.
HTML that never hydrates runs no effects: the root layout, a page without a client bundle, and the not-found and error pages. There the server renders a small inline <script> right after the element that does the same job as soon as the browser parses it, carrying the CSP nonce when your policy uses one. When a soft navigation swaps in such a page, the client router hands its elements to the same observer instead, as scripts in swapped-in HTML never run, and adds the stylesheet if the previous page had none. Inside the hydrated page no such script is rendered. A browser without IntersectionObserver shows these elements at once.
initAnimateObserver
import { initAnimateObserver } from '@gio.js/react';
initAnimateObserver(): voidinitAnimateObserver() creates the shared IntersectionObserver (threshold 0.1) that every <Animate> and observeElement() call uses. It does nothing on the server or when the observer already exists, and observeElement() calls it on first use, so an app never has to.
observeElement
import { observeElement } from '@gio.js/react';
observeElement(el: HTMLElement): voidobserveElement() hands one of your own elements to the shared observer: the first time 10% of it is visible, it gets data-gio-animate-state="entered" and is no longer watched. Use it when the wrapping <div> of <Animate> does not fit - list items, table rows, an element styled by your own CSS. It does nothing on the server.
Examples
Staggering a grid
import { Animate } from '@gio.js/react';
const FEATURES = ['Rust server', 'React rendering', 'Built-in image optimizer'];
export function Features() {
return (
<div className="grid">
{FEATURES.map((feature, i) => (
<Animate key={feature} enter="zoom-in" delay={i * 120} duration={500}>
<h3>{feature}</h3>
</Animate>
))}
</div>
);
}Revealing list items with your own CSS
observeElement only sets the attribute; the effect is yours. The selector works on any element.
import { useEffect, useRef, type ReactNode } from 'react';
import { observeElement } from '@gio.js/react';
import './changelog-list.css';
function Entry({ children }: { children: ReactNode }) {
const ref = useRef<HTMLLIElement>(null);
useEffect(() => {
if (ref.current !== null) observeElement(ref.current);
}, []);
return <li ref={ref} className="reveal">{children}</li>;
}
export function ChangelogList({ entries }: { entries: string[] }) {
return <ul>{entries.map((entry) => <Entry key={entry}>{entry}</Entry>)}</ul>;
}.reveal {
opacity: 0;
transition: opacity 300ms ease;
}
.reveal[data-gio-animate-state='entered'] {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.reveal {
opacity: 1;
transition: none;
}
}Good to know
- The content is invisible until JavaScript runs. It is in the HTML (search engines read it), but at
opacity: 0until the page hydrates (or, in the root layout, until its inline script runs). With JavaScript off it stays invisible. Do not wrap the main heading or the largest image: they would show late. - The wrapper is a
<div>, so<Animate>cannot go inside a<p>, or directly inside a<ul>or<table>. UseobserveElementthere. - The
styleattribute and the hoisted<style>element needstyle-src 'unsafe-inline'under a Content Security Policy. - Elements already in view when the page hydrates animate right away, as the observer reports them on its first check.
Related
<GioLink transition>- animate between pages.- CSS - stylesheets and CSS Modules for your own effects.
- Components - every component in
@gio.js/react.
Version history
| Version | Changes |
|---|---|
v0.1.0-beta.8 | Works in the root layout and in other HTML that never hydrates, through an inline script after the element. The document-wide observer script that pages without a root layout carried is gone. |
v0.1.0-beta.1 | Introduced, with initAnimateObserver and observeElement. |