Glin UI

Motion system

Animation engines

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.

Choosing an engine

All three produce the same visual result. They differ in physics, tooling and weight.

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

The motion package (formerly framer-motion). Real spring physics and interruptible animations. Pick it when feel matters or you already use motion.

gsap

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 engine
pnpm 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)

Bundle cost

Gzipped size of each adapter, measured on the built output.

Gzipped bundle cost per animation engine
EngineAdapter (gzip)LibraryTotal (gzip)Pick it for
css3.8 KBnone3.8 KBDefault. Reveals, counters and staggers with zero dependencies.
motion3.8 KBmotion animate + inView, about 20 KB24.0 KBReal spring physics, interruptible animations, gestures later.
gsap4.0 KBgsap core + ScrollTrigger, about 45 KB48.8 KBTimelines, scrubbing and teams that already ship GSAP.

Live comparison

The same Reveal, StaggerList and CountUp on each engine. Replay to watch them again, or switch animations off.

CSS

Web Animations API

Reveal: blur and slide
  • First
  • Second
  • Third

Downloads: 48,250

Motion

motion package, springs

Reveal: blur and slide
  • First
  • Second
  • Third

Downloads: 48,250

GSAP

gsap + ScrollTrigger

Reveal: blur and slide
  • First
  • Second
  • Third

Downloads: 48,250

Turning animation on and off

Animation is on by default. Visitors and developers choose a level; the engine choice is independent.

Animation levels
LevelBehavior
fullFull motion: travel, blur, scale and counters on the selected engine.
subtleOpacity fades only. Counters jump to the final value. Applied automatically when prefers-reduced-motion is set.
noneStatic. Final styles are applied immediately, no observers, and no animation library is loaded.
systemFollows 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">

Add your own engine

Engines are plugins. Register one by name and every Reveal, SplitText, CountUp and StaggerList can use it.

1. The engine interface

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 visible

2. Write an adapter and register it

Prefer createEngine(adapter): you only provide animate, tween and observe. You can also pass a hand-written MotionEngine straight to registerEngine.

my-engine.ts
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)))

3. Select it

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")