css (default)
Web Animations API plus IntersectionObserver. No dependency, smallest cost. Spring easing is approximated with a bezier curve. Pick it unless you need something below.
Motion system
Animation is a choice, not a constraint. Components that reveal, stagger or count render through the engine you pick, and every one of them can be switched off.
All three produce the same visual result. They differ in physics, tooling and weight.
Web Animations API plus IntersectionObserver. No dependency, smallest cost. Spring easing is approximated with a bezier curve. Pick it unless you need something below.
The motion package (formerly framer-motion). Real spring physics and interruptible animations. Pick it when feel matters or you already use motion.
GSAP core with ScrollTrigger for in-view and a hand-rolled text splitter. Pick it for timelines or when the rest of your site already runs GSAP.
# css is built in, nothing to install. Opt in to the others:pnpm add motion # for the motion enginepnpm add gsap # for the gsap engine
// app entry (layout, providers or main.tsx), once:import "@glinui/motion/register/motion"import "@glinui/motion/register/gsap"
// Manual registration is also available:import { registerEngine } from "@glinui/motion"import { gsapEngine } from "@glinui/motion/engines/gsap"registerEngine("gsap", gsapEngine)Gzipped size of each adapter, measured on the built output.
| Engine | Adapter (gzip) | Library | Total (gzip) | Pick it for |
|---|---|---|---|---|
| css | 3.8 KB | none | 3.8 KB | Default. Reveals, counters and staggers with zero dependencies. |
| motion | 3.8 KB | motion animate + inView, about 20 KB | 24.0 KB | Real spring physics, interruptible animations, gestures later. |
| gsap | 4.0 KB | gsap core + ScrollTrigger, about 45 KB | 48.8 KB | Timelines, scrubbing and teams that already ship GSAP. |
The same Reveal, StaggerList and CountUp on each engine. Replay to watch them again, or switch animations off.
Web Animations API
Downloads: 48,250
motion package, springs
Downloads: 48,250
gsap + ScrollTrigger
Downloads: 48,250
Animation is on by default. Visitors and developers choose a level; the engine choice is independent.
| Level | Behavior |
|---|---|
| full | Full motion: travel, blur, scale and counters on the selected engine. |
| subtle | Opacity fades only. Counters jump to the final value. Applied automatically when prefers-reduced-motion is set. |
| none | Static. Final styles are applied immediately, no observers, and no animation library is loaded. |
| system | Follows prefers-reduced-motion: full normally, subtle when the visitor asks for less motion. |
Precedence is the component props (engine, motion), then MotionEngineProvider (and later GlinProvider), then data-glin-engine and data-glin-motion on <html>, then css with full motion.
import { MotionEngineProvider } from "@glinui/ui"
export function Providers({ children }: { children: React.ReactNode }) { return ( <MotionEngineProvider engine="motion" motion="system"> {children} </MotionEngineProvider> )}
// Or without React state:// <html data-glin-engine="gsap" data-glin-motion="none">Engines are plugins. Register one by name and every Reveal, SplitText, CountUp and StaggerList can use it.
Every method returns a cleanup. Cleanups must be idempotent and leave the element in its final, visible state, which keeps React Strict Mode and engine switching safe. Never touch window at import time.
interface MotionEngine { readonly name: string readonly capabilities: EngineCapabilities reveal(el: HTMLElement, opts?: EngineRevealOptions): EngineCleanup stagger(els: ArrayLike<HTMLElement>, opts?: EngineStaggerOptions): EngineCleanup countTo(el: HTMLElement, from: number, to: number, opts?: EngineCountOptions): EngineCleanup splitText?(el: HTMLElement, opts?: EngineSplitOptions): EngineCleanup timeline?(steps: readonly EngineTimelineStep[], opts?: EngineTimelineOptions): EngineCleanup}
type EngineCleanup = () => void // stop, disconnect observers, leave the element visiblePrefer createEngine(adapter): you only provide animate, tween and observe. You can also pass a hand-written MotionEngine straight to registerEngine.
import { createEngine, registerEngine, type EngineAdapter } from "@glinui/motion"
// 1. Implement three small primitives. createEngine builds reveal, stagger,// countTo and splitText on top of them (in-view, delays, cleanup, replay).const myAdapter: EngineAdapter = { name: "my-engine", capabilities: { spring: false, scrollTrigger: false, timeline: false, splitText: true, runtime: "library" },
// Animate one element from `from` to its natural state. animate(el, from, timing, onComplete) { return { reset: () => { el.style.opacity = String(from.opacity) }, play: (extraDelayMs) => myLib.to(el, { opacity: 1, duration: timing.durationMs, delay: timing.delayMs + extraDelayMs, onDone: onComplete }), dispose: () => { myLib.kill(el); el.style.removeProperty("opacity") } } },
// Tween a number (used by countTo). tween(from, to, timing, onUpdate, onComplete) { return { play: (extra) => myLib.value(from, to, timing.durationMs, onUpdate, onComplete, extra), stop: () => myLib.stopValue() } },
// Tell us when the target is visible. observe(target, { threshold, rootMargin }, enter, leave) { const io = new IntersectionObserver(([e]) => (e.isIntersecting ? enter() : leave()), { threshold, rootMargin }) io.observe(target) return () => io.disconnect() }}
// 2. Register it. Use a factory so the library is only fetched when selected.registerEngine("my-engine", () => import("./my-engine").then((m) => createEngine(m.myAdapter)))import { MotionEngineProvider, Reveal } from "@glinui/ui"
// Per component<Reveal engine="my-engine">...</Reveal>
// For a subtree<MotionEngineProvider engine="my-engine"><App /></MotionEngineProvider>
// Without React state (picked up live)document.documentElement.setAttribute("data-glin-engine", "my-engine")