CSS Animations skill for Codex
Smooth motion with zero JavaScript. Codex picks between transitions, keyframes, @starting-style and scroll timelines based on what the motion needs, animates only compositor-friendly properties, and wraps newer features in @supports so Firefox or older Safari get a correct static page. Reduced-motion handling is built in from the start and checked in DevTools rather than bolted on.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- css-animations Installs to ~/.agents/skills/css-animations/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install css-animations The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project css-animations Installs to .agents/skills/css-animations/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/css-animations.zipunzip -o css-animations.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) css-animations PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $css-animations, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Builds and fixes CSS transitions, @keyframes, scroll-driven animations (animation-timeline scroll() and view()), @starting-style entry effects and @property-animated custom properties, keeping motion on the compositor and honoring prefers-reduced-motion. Use when a user asks to animate an element without JavaScript, add a fade or slide on enter, animate to height auto, build a scroll progress bar or reveal-on-scroll, fix janky or stuttering CSS animation, or make motion accessible.
Use it when
- An interaction needs hover, focus or open/close motion in plain CSS
- Content should reveal or progress with scroll without a JS scroll listener
- An existing CSS animation stutters, flashes on load or ignores reduced motion
Use something else when
- Exit animations for React components removed from the tree: use motion-animations
- Pinned, scrubbed or multi-step scroll storytelling: use gsap-animations
- Choosing easing and duration for a product's motion language: use motion-design-principles
How to check the result
Record the interaction in the DevTools Performance panel with paint flashing on, confirm no per-frame layout work, then emulate prefers-reduced-motion and check the static fallback in a non-supporting browser.
The mistake it prevents
Writing transition: all, or placing animation-timeline before the animation shorthand, which silently resets it to auto.
Done well means: every animation runs on transform, opacity or filter (or is short and
off the critical path), there is a reduced-motion path, nothing flashes or jumps on first
paint, and the effect degrades to a static but correct UI where a feature is unsupported.
Before writing anything
- Read
AGENTS.mdand the styling setup (plain CSS, CSS Modules, Tailwind, CSS-in-JS). Put keyframes where the project already keeps them; with Tailwind v4 that is usually@theme(--animate-*) plus@keyframesin the main CSS file. - Search for existing motion tokens (
--duration-*,--ease-*) and reuse them. - Check the browserslist / support policy. It decides whether scroll-driven animations and
@starting-stylecan be the only path or must be progressive enhancement. - Decide if CSS is the right tool (table below) before writing code.
CSS or JavaScript?
| Need | Use |
|---|---|
| Hover, focus, pressed, open/closed state change | CSS transition |
| Looping or multi-step decorative motion | @keyframes |
Element appears (inserted, display:none → block, popover, dialog) | @starting-style + transition-behavior: allow-discrete |
| Progress tied to scroll position or element visibility | animation-timeline: scroll() / view() |
| Exit animation for an element React/Vue removes from the DOM | JS library ($motion-animations) or delay removal until animationend |
| Sequenced timelines, scrubbing, pinning, physics, drag | $gsap-animations or $motion-animations |
| Page or route change between two DOM states | $view-transitions |
Transitions
.card {
transition:
transform 180ms var(--ease-out, cubic-bezier(0.2, 0, 0, 1)),
box-shadow 180ms ease-out;
}
.card:hover { transform: translateY(-2px); }
- Never write
transition: all. It animates layout properties you did not intend, including ones changed by unrelated classes, and it makes theme switches crawl. - Durations: 100–200 ms for small UI feedback, 200–350 ms for panels and overlays, rarely above 500 ms. Exits ~20–30% shorter than entrances.
- Prefer individual transform properties (
translate,scale,rotate) when several rules contribute; they compose instead of overwriting onetransformstring.
Entry effects with @starting-style
Transitions do not run on first render or from display: none. @starting-style gives the
“before” values; allow-discrete lets display and overlay flip at the right moment.
[popover], dialog {
opacity: 1;
translate: 0 0;
transition:
opacity 200ms ease-out,
translate 200ms ease-out,
display 200ms allow-discrete,
overlay 200ms allow-discrete;
}
@starting-style {
[popover]:popover-open, dialog[open] { opacity: 0; translate: 0 8px; }
}
[popover]:not(:popover-open), dialog:not([open]) { opacity: 0; translate: 0 8px; }
Unsupported browsers simply show the element without the fade; that is the intended fallback.
Animating to height: auto
| Option | Support note | Use when |
|---|---|---|
interpolate-size: allow-keywords on :root | Chromium only today | Progressive enhancement; others snap |
grid-template-rows: 0fr → 1fr with an inner overflow: hidden wrapper | Broad | Accordions that must animate everywhere |
<details> with ::details-content | Chromium-led | Native disclosure where snapping is acceptable elsewhere |
.collapse { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 250ms ease; }
.collapse[data-open="true"] { grid-template-rows: 1fr; }
.collapse > .inner { overflow: hidden; }
Keyframes
@keyframes spin { to { rotate: 360deg; } }
.spinner { animation: spin 0.9s linear infinite; }
@keyframes rise {
from { opacity: 0; translate: 0 12px; }
}
.reveal { animation: rise 300ms ease-out both; animation-delay: calc(var(--i, 0) * 40ms); }
- Omit
towhen the end state is the element’s normal style; the animation lands on it. - Use
both(orbackwards) fill mode when a delay would otherwise flash the final state. - Stagger with a
--icustom property set inline (style="--i: 3") rather than nth-child chains. - To animate a custom property (gradient angle, color stop), register it first; unregistered custom properties flip discretely:
@property --angle { syntax: "<angle>"; inherits: false; initial-value: 0deg; }
.ring { background: conic-gradient(from var(--angle), #6366f1, #22d3ee, #6366f1);
animation: turn 4s linear infinite; }
@keyframes turn { to { --angle: 360deg; } }
linear()easing approximates springs and bounces in pure CSS; generate the point list with a tool rather than hand-writing it.
Scroll-driven animations
Supported in Chromium and in Safari 26; Firefox still keeps it behind a flag (on the Interop
2026 list). Always wrap in @supports so other browsers get the static end state.
@supports (animation-timeline: view()) {
@keyframes fade-up { from { opacity: 0; translate: 0 24px; } }
.reveal-on-scroll {
animation: fade-up linear both;
animation-timeline: view();
animation-range: entry 10% cover 30%;
}
@keyframes grow { from { scale: 0 1; } }
.progress-bar {
position: fixed; inset: 0 0 auto; height: 3px; transform-origin: 0 50%;
animation: grow linear both;
animation-timeline: scroll(root block);
}
}
- Put
animation-timelineafter theanimationshorthand; the shorthand resets it toauto. - Leave the time out of the shorthand: with a scroll or view timeline the duration resolves
to
auto. Early flagged implementations wanted an explicit one;1msis a harmless placeholder. view()measures the element’s own box. If the element is transformed by the animation, animate a child or the measured range shifts while it moves.- Use named timelines (
view-timeline-name: --card,timeline-scope) when the animated element is not the one being tracked.
Reduced motion
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 1ms !important;
animation-iteration-count: 1 !important;
transition-duration: 1ms !important;
scroll-behavior: auto !important;
}
}
Use the global reset only as a safety net. Better: author motion inside
@media (prefers-reduced-motion: no-preference) so the default is still, and replace large
movement with an opacity fade instead of removing feedback entirely. Parallax, zooms and
autoplaying loops longer than 5 seconds must stop or be pausable (WCAG 2.2.2, 2.3.3).
Performance
| Property | Cost |
|---|---|
transform, opacity, filter (most) | Compositor only, cheap |
background-color, color, box-shadow | Paint every frame; fine for small elements |
width, height, top, left, margin, grid-template-* | Layout every frame; keep short and contained |
will-change: transformonly on elements about to animate, removed afterward. Applied to many elements it multiplies GPU memory and can make things slower.- Animating
box-shadowon large cards: animate the opacity of a pseudo-element that already has the final shadow instead. contain: layout painton an animated widget keeps layout work from spreading.
Gotchas
- Changing a class from
display: noneand adding a transition in the same frame shows no transition; use@starting-styleor force a style flush. animationon:hoverrestarts on every hover; atransitionreverses smoothly instead.transformon a parent creates a containing block:position: fixedchildren stop being viewport-fixed. Common cause of “modal jumps inside animated panel”.- Keyframes declared inside CSS Modules get hashed names; reference them from the same file.
steps()sprites needimage-renderingand integer pixel sizes or they shimmer.- Safari needs the
-webkit-prefix forbackdrop-filterin older versions; animating it is expensive everywhere.
Verification
- DevTools Performance panel: record the interaction; no long purple (layout) or green (paint) bars per frame on the animated element; frames stay near 16.7 ms.
- Rendering tab: enable “Paint flashing” and “Layer borders”; only the intended layer repaints.
- Emulate
prefers-reduced-motion: reduce(Rendering tab) and confirm motion is removed or replaced, and the UI still communicates state. - Animations panel: slow to 10% to check easing, overlap and final position.
- Test the unsupported path: in Firefox (scroll timelines) the content is visible and static.
- If a Playwright suite exists, set
reducedMotion: 'reduce'in the context to keep screenshots stable.
Output
When delivering, state: which elements animate, the properties used and why, duration/easing tokens, the reduced-motion behavior, and which features are progressive enhancement and what non-supporting browsers show.
What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "CSS Animations"
short_description: "Smooth motion with zero JavaScript"
default_prompt: "$css-animations Fade and slide this popover in when it opens, no JS library" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.