animation
Small movements, like a toast sliding in or a dialog growing out of its button, tell people what just changed. @kitr/animation gives every component the same timings, turns movement off for visitors who ask for less of it, and animates page changes like a native app.
It is Motion for React, re-exported unchanged. kitr adds:
- A provider that applies
config/animation.tsto the whole app. transitionsandpresets, so durations and easings are shared.<Reveal>for content that appears as it scrolls into view.pageTransitions()for animated route changes through the View Transitions API.
Motion animates DOM and SVG elements. For 3D scenes, use 3d.
The examples animate Acme's project dashboard at app.acme.com.
Install
kitr add animationFirst time? Run npm i -g @kitr/kitr once. Inside a project, the global kitr runs your project's own version.
Everything kitr add changes
What kitr add does:
- Adds
@kitr/animationandmotiontodependenciesand installs them. Pass--no-installto skip the install. - Adds
'@kitr/animation/provider'toprovidersinkitrrc.ts. - Writes
config/animation.ts, shown under Configure. An existing file is left alone. - Adds a
## kitr: animationsection toAGENTS.md: import from@kitr/animation, neverframer-motionormotion/reactdirectly; spreadpresetsfor enter and exit; use<Reveal>for scroll-in sections andpageTransitions()for page changes; leave reduced motion to the config.
It asks no questions. It adds the react package first when the project has none, and refuses in a server-only project.
kitr remove animation takes the provider out of kitrrc.ts, removes both dependencies and the AGENTS.md section, then lists files that still import @kitr/animation. It leaves config/animation.ts in place.
kitr doctor flags imports from motion/react and framer-motion, so every component goes through the same provider.
Configure
kitr add writes this file:
// config/animation.ts
import { defineConfig, transitions } from '@kitr/animation'
export default defineConfig({
// 'user' follows the visitor's "reduce motion" setting: movement stops, fades still run.
reducedMotion: 'user',
// Every motion element uses this unless it sets its own transition.
transition: transitions.base,
// Animations finish instantly under Vitest. Set true for screenshot tests too.
// skipAnimations: true,
})
The provider passes it to Motion's MotionConfig around the app.
| Option | Default | Description |
|---|---|---|
reducedMotion |
'user' |
'user' follows the system's reduce-motion setting, 'always' always reduces, 'never' ignores it. Reduced motion keeps fades and drops movement. |
transition |
transitions.base |
The transition for any motion element that sets none: 0.25 seconds on a decelerating curve. |
skipAnimations |
true under Vitest, false elsewhere |
Jump every animation straight to its final values. Exit animations still finish, so elements still leave the DOM. |
nonce |
none | A Content Security Policy nonce for the <style> tags Motion adds for layout and exit animations. |
Animate your first component
A toast that rises in and falls away when closed. Wrap it in AnimatePresence so Motion can run the exit animation before React removes it:
// app/components/invoice-sent-toast.tsx
import { AnimatePresence, motion, presets } from '@kitr/animation'
export function InvoiceSentToast({ invoice, onClose }: { invoice: string | null; onClose: () => void }) {
return (
<AnimatePresence>
{invoice && (
<motion.div key={invoice} role="status" className="toast" {...presets.fadeUp}>
Invoice {invoice} sent to [email protected]
<button type="button" onClick={onClose}>
Close
</button>
</motion.div>
)}
</AnimatePresence>
)
}
Render it with <InvoiceSentToast invoice={sent} onClose={() => setSent(null)} />.
When invoice is set, the toast fades in while rising 12 pixels. When it becomes null, it fades out and only then leaves the DOM. The key makes a second invoice replace the first with its own exit and entry.
presets.fadeUp is plain data: { initial, animate, exit, transition }. A prop written after the spread wins.
Use the shared timings
Every duration comes from transitions, so a change of feel is one edit:
// app/components/project-card.tsx
import { motion, transitions } from '@kitr/animation'
export function ProjectCard({ name }: { name: string }) {
return (
<motion.article whileHover={{ y: -2 }} whileTap={{ scale: 0.98 }} transition={transitions.fast}>
<h3>{name}</h3>
</motion.article>
)
}
| Name | Value | Use it for |
|---|---|---|
fast |
0.15s, decelerating | Hover, press and small state changes |
base |
0.25s, decelerating | Things entering the screen. The app default |
slow |
0.4s, decelerating | Sheets, drawers and large panels |
exit |
0.2s, accelerating | Things leaving the screen |
snappy |
spring, no overshoot | Dragging, reordering, layout changes |
bouncy |
spring, slight overshoot | Likes, badges and other playful confirmations |
Durations are in seconds.
Enter and exit with presets
Each preset is a set of props to spread on a motion element:
| Preset | Enters as | Use it for |
|---|---|---|
fade |
Opacity 0 to 1 | Anything; nothing moves, so it is safe under reduced motion |
fadeUp |
Rises 12px while fading in | List items, cards, bottom toasts |
fadeDown |
Drops 12px while fading in | Menus, top toasts |
scale |
Grows from 95% while fading in | Dialogs, popovers, image previews |
slideLeft |
Slides 24px from the right | Drawers and detail panels on the right |
slideRight |
Slides 24px from the left | Navigation drawers |
collapse |
Opens from zero height to its content's height | Accordions, expanding rows |
Each exits the way it came in. A dialog that grows in from the centre:
// app/components/new-project-dialog.tsx
import { AnimatePresence, motion, presets } from '@kitr/animation'
export function NewProjectDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
return (
<AnimatePresence>
{open && (
<motion.div key="backdrop" className="backdrop" {...presets.fade} onClick={onClose}>
<motion.div
role="dialog"
aria-label="New project"
className="dialog"
{...presets.scale}
onClick={(event) => event.stopPropagation()}
>
<h2>New project</h2>
{/* ... */}
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}
Override one part after the spread. A toast that waits a moment:
<motion.div {...presets.fadeUp} transition={{ ...presets.fadeUp.transition, delay: 0.3 }} />
Expand and collapse a section
presets.collapse animates height to 'auto', which CSS transitions cannot do:
// app/components/project-row.tsx
import { AnimatePresence, motion, presets } from '@kitr/animation'
import { useState } from 'react'
export function ProjectRow({ name, invoices }: { name: string; invoices: string[] }) {
const [open, setOpen] = useState(false)
return (
<li>
<button type="button" aria-expanded={open} onClick={() => setOpen(!open)}>
{name}
</button>
<AnimatePresence initial={false}>
{open && (
<motion.ul key="invoices" {...presets.collapse}>
{invoices.map((invoice) => (
<li key={invoice}>{invoice}</li>
))}
</motion.ul>
)}
</AnimatePresence>
</li>
)
}
initial={false} stops rows that start open from animating on first render. Keep padding on an element inside the collapsing one; padding on the animated element shows at zero height.
Reveal content as it scrolls into view
<Reveal> animates its content the first time it scrolls into view:
// app/pages/home.tsx
import { Reveal } from '@kitr/animation'
export function Features() {
return (
<Reveal as="section" preset="fadeUp">
<h2>Invoices that send themselves</h2>
<p>Acme bills your clients on the first of every month.</p>
</Reveal>
)
}
To bring items in one after another, set stagger (seconds between items) and wrap each in <RevealItem>:
// app/components/project-grid.tsx
import { Reveal, RevealItem } from '@kitr/animation'
export function ProjectGrid({ projects }: { projects: Array<{ id: string; name: string }> }) {
return (
<Reveal as="ul" stagger={0.05} className="grid">
{projects.map((project) => (
<RevealItem as="li" key={project.id}>
{project.name}
</RevealItem>
))}
</Reveal>
)
}
With stagger, the container stays still and each item plays the preset in turn. A <RevealItem> outside a staggered <Reveal> does not animate.
| Prop | Default | Description |
|---|---|---|
as |
'div' |
The element to render: div, section, article, aside, header, footer, main, figure, ul, ol, li, p or span |
preset |
'fadeUp' |
Any name from presets |
once |
true |
Animate the first time only; false hides it again when it scrolls out |
amount |
0.2 |
How much must be visible: a fraction, 'some' or 'all' |
delay |
0 |
Seconds to wait once it is in view; with stagger, the wait before the first item |
stagger |
none | Seconds between <RevealItem> children |
Every other motion.div prop, such as className, passes through.
Animate page changes
pageTransitions() animates route changes with the browser's View Transitions API; the back button slides the other way. Pass it to the router as defaultViewTransition and import the stylesheet once:
// app/router.tsx
import { pageTransitions } from '@kitr/animation'
import '@kitr/animation/page-transitions.css'
import { createKitrRouter } from '@kitr/router'
import { app } from './app'
import { routes } from './routes'
export function getRouter() {
return createKitrRouter({
routes,
app,
defaultViewTransition: pageTransitions(),
})
}
The direction comes from the router's history index, so opening /projects/42 slides forward and the back button, a swipe back or history.back() slides back.
| Option | Default | Description |
|---|---|---|
style |
'slide' |
'slide' moves pages sideways like a native app; 'fade' cross-fades them |
reducedMotion |
'user' |
'user' changes pages instantly for visitors who ask for less motion. 'always' turns page animations off; 'never' keeps them |
onSearchChange |
false |
Also animate when only the search or hash changes, such as the next page of a table |
Browsers without view transitions change pages instantly. Chromium, Electron, Android WebView and Safari 18 support them. Pass viewTransition to a single link to animate it differently, or viewTransition={false} for one that should never animate.
Keep the header still during page changes
By default the whole page slides. Give the header and navigation their own view-transition-name and they stay in place:
// app/pages/app-layout.tsx
import { Outlet } from '@kitr/router'
export default function AppLayout() {
return (
<>
<header style={{ viewTransitionName: 'app-header' }}>Acme</header>
<nav style={{ viewTransitionName: 'app-nav' }}>{/* ... */}</nav>
<main>
<Outlet />
</main>
</>
)
}
Morph a card into its page
Give the same name to an element on two pages and the browser moves and resizes it between them:
// app/components/project-cover.tsx
export function ProjectCover({ id, src }: { id: string; src: string }) {
return <img src={src} alt="" style={{ viewTransitionName: `project-cover-${id}` }} />
}
Use <ProjectCover> in both the list and the project page. The id is part of the name because only one element per name may be on screen.
Animate layout changes
When a list is re-sorted, filtered or loses an item, layout animates every item to its new position:
// app/components/sorted-projects.tsx
import { AnimatePresence, motion, presets, transitions } from '@kitr/animation'
export function SortedProjects({ projects }: { projects: Array<{ id: string; name: string }> }) {
return (
<ul>
<AnimatePresence initial={false}>
{projects.map((project) => (
<motion.li key={project.id} layout transition={transitions.snappy} {...presets.fade}>
{project.name}
</motion.li>
))}
</AnimatePresence>
</ul>
)
}
Use transitions.snappy for layout so items settle without bouncing. Under reduced motion the list updates in place.
Respect reduced motion in your own code
The provider handles motion elements. For anything else that moves, such as an autoplaying video, read the setting with useReducedMotionConfig(), which follows config/animation.ts:
// app/components/hero-video.tsx
import { useReducedMotionConfig } from '@kitr/animation'
export function HeroVideo() {
const reduce = useReducedMotionConfig()
return <video src="/videos/acme-tour.mp4" autoPlay={!reduce} muted loop playsInline controls={reduce ?? false} />
}
It returns true when movement should stop. Outside the provider, prefersReducedMotion() reads the operating system setting directly.
Testing
Under Vitest every animation jumps to its end and exits remove their element at once, so tests need no timers. Render with createTestApp and renderWithKitr from testing:
// app/components/invoice-sent-toast.test.tsx
import { animationProvider } from '@kitr/animation/provider'
import { createTestApp } from '@kitr/testing'
import { renderWithKitr } from '@kitr/testing/react'
import { screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { InvoiceSentToast } from './invoice-sent-toast'
describe('invoice sent toast', () => {
it('appears fully visible and leaves when closed', async () => {
const app = await createTestApp({ providers: [animationProvider] })
const onClose = vi.fn()
const { rerender } = renderWithKitr(<InvoiceSentToast invoice="INV-1042" onClose={onClose} />, { app })
const toast = screen.getByRole('status')
expect(toast.textContent).toContain('INV-1042')
await waitFor(() => expect(toast.style.opacity).toBe('1'))
rerender(<InvoiceSentToast invoice={null} onClose={onClose} />)
await waitFor(() => expect(screen.queryByRole('status')).toBeNull())
})
})
<Reveal> needs an IntersectionObserver; happy-dom's never fires, so stub one that reports every element as visible:
// tests/setup.ts
import { vi } from 'vitest'
vi.stubGlobal(
'IntersectionObserver',
class {
constructor(private callback: IntersectionObserverCallback) {}
observe(target: Element) {
this.callback([{ target, isIntersecting: true, intersectionRatio: 1 } as IntersectionObserverEntry], this as never)
}
unobserve() {}
disconnect() {}
},
)
For page transitions, call the function with two history indexes:
// app/router.test.ts
import { pageTransitions } from '@kitr/animation'
import { expect, it } from 'vitest'
it('slides back on the back button', () => {
const { types } = pageTransitions()
const at = (index: number) => ({ state: { __TSR_index: index } })
expect(types({ fromLocation: at(4), toLocation: at(3), pathChanged: true, hrefChanged: true })).toEqual([
'kitr-slide',
'kitr-back',
])
})
In Playwright, pass reducedMotion: 'reduce' to the browser context to check that path, and set skipAnimations: true for stable screenshots.