→ Transitions, keyframes and attribute-driven motion — lightest tool first.
Animations
"Animation" in Salty CSS is really three tools wearing one coat, and the trick is reaching for the lightest one that does the job:
- Transitions — the browser tweens between two states you already have (a hover, a focus, a flipped attribute). No keyframes, no JavaScript. The lightest tool, and the one people skip past too often.
- Keyframes —
keyframes()for anything multi-step or self-running: an entrance, a pulse, a spinner. This is the API this page is mostly about. - State-driven motion — an attribute the browser (or a few lines of your own JS) flips, plus a transition to carry the change. This is how you do things like an on-scroll entrance without a keyframe or React state.
The mindset worth carrying through every example: a lot of what people build with keyframes is really just a transition between two states the browser is already tracking. Keyframes are the right call for genuinely multi-step motion — but they aren't the only way, and they aren't automatically the "proper" way. When the platform can already see the state you want to animate, let it, and keep the keyframe for when there's real choreography to describe.
Everything below is authored in TypeScript in a .css.ts file and compiled to a static stylesheet at build time. Transitions and keyframes ship as plain CSS — nothing animation-related runs in the browser. The one place your own JavaScript shows up is Example 4, and even there it's a handful of lines setting an attribute, not a styling runtime.
How animations work in Salty, briefly
Two of the three tools are just CSS you already know, reached through the same Salty style objects you write everywhere else:
- Transitions are the plain
transitionproperty, dropped into anybase, variant, or pseudo-selector. There's no special API — you writetransition: "opacity 200ms ease"next to the state that changesopacity, and the browser interpolates. - Keyframes are the one animation primitive Salty adds:
keyframes({...})defines an@keyframesrule and hands you back a value that drops straight into theanimationproperty. It carries its own timing defaults and can be called with overrides at the call site.
The ordering to keep in your head is the same one the Interactive State page uses for state generally — climb only as far as the effect forces you to. A two-state change on hover is a transition. A multi-step or looping motion is a keyframe. A whole-document behaviour like "fade things in as they enter the viewport" is an attribute plus a transition, wired up once and forgotten. Three rungs, lightest first.
Example 1 — Transitions: the lightest tool
Start here, because a surprising share of "I need an animation" is really "I need the browser to move between two states smoothly." When the state is something the browser already tracks — hover, focus, active — you don't need a keyframe at all. You need one transition line and the state you're transitioning to.
How you define it
Put the transition on the resting style, then change the property inside the state selector. The browser tweens the difference:
import { styled } from "@salty-css/react/styled";
export const Card = styled("article", {
base: {
padding: "1rem",
borderRadius: "8px",
background: "{theme.bg}",
// declare what animates, and how, once on the resting state
transition: "transform 200ms ease, box-shadow 200ms ease",
// the browser already knows when this is hovered — just describe the target
"&:hover": {
transform: "translateY(-4px)",
boxShadow: "0 8px 24px rgba(0,0,0,0.12)",
},
},
});How you use it
There's nothing to wire up — no prop, no state, no import at the call site. You render the component and the motion is already in it:
import { Card } from "./card.css";
export function Teaser() {
return <Card>Hover me and I lift.</Card>;
}That's the whole tool. transition is standard CSS — the MDN transition reference covers every property, timing function, and per-property duration, and it all works verbatim inside a Salty style object. The reason it's worth calling out first is that people reach past it: they write a keyframe for a hover effect a transition would have handled in one line. If the change is from one state to another and the browser can see the trigger, this is your tool. The full set of triggers the browser tracks for free — :focus-within, :has(), [open], aria-*, and more — lives on the Interactive State page; every one of them can drive a transition exactly like :hover does here.
Example 2 — Keyframes: define once, drop into animation
When the motion has more than two steps, or needs to run on its own rather than in response to a state, that's a keyframe. keyframes() defines the @keyframes rule and returns a value you use as the animation shorthand.
How you define it
The keys are standard CSS keyframe selectors — from, to, percentage strings, or plain numbers (treated as percentages) — and the values are ordinary Salty style objects:
import { keyframes } from "@salty-css/react/keyframes";
// Simple from/to
export const fadeIn = keyframes({
from: { opacity: 0 },
to: { opacity: 1 },
});
// Multi-step with percentage keys
export const riseIn = keyframes({
"0%": { transform: "translateY(100%)", opacity: 0 },
"50%": { opacity: 0.5 },
"100%": { transform: "translateY(0)", opacity: 1 },
});
// Numeric keys work too — they're read as percentages
export const pulse = keyframes({
0: { transform: "scale(1)" },
50: { transform: "scale(1.05)" },
100: { transform: "scale(1)" },
});One rule that bites everyone once: every keyframes() call must be a top-level export of a .css.ts file. That's how the compiler finds it. Call it lazily, or leave it unexported, and you get no @keyframes rule and a very confusing silence. (There's a helper pattern for sharing a keyframe shape without breaking this rule — see the end of the page.)
How you use it
A keyframe value drops straight into animation. Salty expands it into the full shorthand for you — animation: name duration easing delay iteration-count direction fill-mode play-state:
import { styled } from "@salty-css/react/styled";
import { fadeIn, riseIn } from "../styles/animations.css";
export const Wrapper = styled("div", {
base: { animation: fadeIn },
});
export const Banner = styled("p", {
base: { animation: riseIn },
});Tuning the animation
keyframes() takes three configuration options alongside the frames: a stable name, an anti-flash flag, and the default timing parameters.
export const fadeIn = keyframes({
// 1. A readable name in the CSS output and DevTools. Omit it and Salty hashes one.
animationName: "fadeIn",
// 2. Inline the starting frame on the element so it doesn't flash in its
// un-animated state before the animation begins — matters most with a delay.
appendInitialStyles: true,
// 3. Default timing. Every one of these is overridable at the call site.
params: {
duration: "500ms",
delay: "0s",
easing: "ease-in-out",
fillMode: "forwards",
},
from: { opacity: 0 },
to: { opacity: 1 },
});The value keyframes() returns is callable — call it with a params object to reuse one definition at different timings, which is almost always better than defining three near-identical keyframes:
animation: fadeIn, // the defaults
animation: fadeIn({ duration: "200ms", easing: "ease-out" }),
animation: fadeIn({ duration: "2s", iterationCount: "infinite", direction: "alternate" }),Here's the full set of timing params and their defaults, so you're not guessing while you author:
| Param | Default | Maps to CSS |
|---|---|---|
duration | 500ms | animation-duration |
delay | 0s | animation-delay |
iterationCount | 1 | animation-iteration-count |
easing | ease-in-out | animation-timing-function |
direction | normal | animation-direction |
fillMode | forwards | animation-fill-mode |
playState | running | animation-play-state |
On appendInitialStyles: without it, an element using fadeIn with a delay renders fully visible (its natural state) for the length of the delay, then snaps to opacity: 0 when the animation kicks in — a visible flicker. With appendInitialStyles: true, Salty inlines the from (or 0%) frame onto the element so it holds the starting state from the very first paint. Turn it on for anything delayed or staggered.
One honest catch, and it's not Salty's — it's motion itself. Some people have prefers-reduced-motion set for real reasons, and a page that ignores it can make them ill. Gate anything non-essential behind the media query:
export const Wrapper = styled("div", {
base: {
animation: fadeIn,
"@media (prefers-reduced-motion: reduce)": {
animation: "none",
},
},
});prefers-reduced-motion is a first-class named query — see Breakpoints & responsive layouts for the builder form if you'd rather define it once and reuse it.
Example 3 — Keyframes in real components
The two things you'll actually reach for most: choosing an animation per instance with a variant, and staggering a list.
As a variant
Because a keyframe is just a value, it composes with variants like anything else — toggle it, swap it, or turn it off:
import { styled } from "@salty-css/react/styled";
import { fadeIn, pulse } from "../styles/animations.css";
export const AnimatedButton = styled("button", {
base: {
padding: "0.5rem 1rem",
borderRadius: "4px",
border: "none",
background: "{theme.buttonBg}",
color: "{theme.buttonText}",
},
variants: {
entrance: {
fade: { animation: fadeIn },
pulse: { animation: pulse({ iterationCount: "infinite" }) },
none: {},
},
},
defaultVariants: { entrance: "fade" },
});<AnimatedButton entrance="pulse">Notice me</AnimatedButton>Staggered lists
To stagger, share one keyframe and compute the delay from each item's index. Don't reach for a per-index variant here — you'd be writing a row per position and capping the list at however many rows you wrote. Use a prop token instead: {props.index} compiles to a CSS variable Salty reads at runtime, so one static rule staggers a list of any length:
import { styled } from "@salty-css/react/styled";
import { fadeIn } from "../styles/animations.css";
export const StaggeredItem = styled("li", {
base: {
animation: fadeIn,
// one rule, any list length — the delay is calculated from the item's index
animationDelay: "calc({props.index} * 80ms)",
},
});The token is the camelCase name ({props.index}); the JSX prop is its dash-cased twin (css-index). Pass the array index straight in — no Math.min cap, because nothing is hard-coded per position:
import { StaggeredItem } from "./staggered-item.css";
export function ItemsList() {
const items = ["One", "Two", "Three", "Four", "Five"];
return (
<ul>
{items.map((item, i) => (
<StaggeredItem key={item} css-index={i}>{item}</StaggeredItem>
))}
</ul>
);
}Under the hood css-index lands as --props-index on the element and your compiled calc(var(--props-index) * 80ms) reads it — so the stagger step lives in the stylesheet and only the index rides through at runtime. If you'd rather compute the whole delay at the call site, pass a ready value like "400ms" to a {props.delay} token instead and read it directly as animationDelay: "{props.delay}"; the prop-token mechanics, fallbacks, and when to prefer each are on the Dynamic Values page.
Set appendInitialStyles: true on fadeIn for this — otherwise each item flashes visible during its delay before dropping back to hidden. Delayed motion is exactly the case the flag exists for.
Pausing declaratively
To pause and resume from a selector instead of from JavaScript — hover-to-pause a marquee, freeze a carousel — you don't need to restate the animation. Just set the animationPlayState longhand under the state; it toggles the running animation in place:
export const Marquee = styled("div", {
base: {
animation: pulse({ iterationCount: "infinite", duration: "10s" }),
// pause without repeating the whole shorthand — just flip the play state
"&:hover": { animationPlayState: "paused" },
},
});(The keyframes() params also accept a playState, which is handy when you're building the shorthand anyway — but for a plain pause-on-state, the longhand above is cleaner because the animation is declared once.)
Example 4 — State-driven entry animations
"Animate on scroll" is the classic reason people install a whole animation library — and it's a clean illustration of the lightest-tool idea, because you don't need one. This uses no keyframe, no React state, and no dependency. The goal is to fade elements in as they enter the viewport, and the pieces are an attribute, a global style, and about eight lines of your own JavaScript.
The workflow:
- Mark each element you want to animate with
data-entry="fade-in". - A small global style hides anything marked-but-not-yet-visible, and adds a transition.
- A tiny script watches those elements and flips
data-entry-visible="true"when they near the screen. The transition carries them from hidden to visible.
How you define it
The styling is a global, because it applies to bare attribute selectors across the whole document rather than to one component — exactly what defineGlobalStyles is for:
import { defineGlobalStyles } from "@salty-css/core/factories";
export const entryStyles = defineGlobalStyles({
// Everything opted in gets the transition that carries the change.
"[data-entry]": {
transition: "opacity 600ms ease, transform 600ms ease",
},
// Hidden until the script marks it visible. The :not() is the whole trick:
// once data-entry-visible="true" is set, this rule stops matching and the
// element returns to its natural opacity: 1 — no second rule needed.
"[data-entry='fade-in']:not([data-entry-visible='true'])": {
opacity: 0,
transform: "translateY(1rem)",
},
// Respect a reduced-motion preference: skip the hide entirely, so content
// just appears instead of moving.
"@media (prefers-reduced-motion: reduce)": {
"[data-entry]": { transition: "none" },
"[data-entry='fade-in']:not([data-entry-visible='true'])": {
opacity: 1,
transform: "none",
},
},
});How you use it
On the markup side, you add one attribute — no class to import, no component to wrap. Any element works, including your Salty components (<Card data-entry="fade-in" />):
<section data-entry="fade-in">I fade in when you scroll to me.</section>
<img data-entry="fade-in" src="/salt.jpg" alt="" />Then a single client-side script does the watching. This is plain JavaScript — your code, not a Salty runtime — and an IntersectionObserver keeps it cheap:
// runs once on the client (e.g. a small module imported in your root layout)
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
entry.target.setAttribute("data-entry-visible", "true");
io.unobserve(entry.target); // fire once, then stop watching
}
}
},
{ rootMargin: "0px 0px -10% 0px" }, // trigger just before it's fully in view
);
document.querySelectorAll("[data-entry]").forEach((el) => io.observe(el));That's the entire pattern. The observer flips the attribute, the :not() selector stops matching, and the transition you declared globally carries every marked element from opacity: 0 up to its natural state. It costs one static global rule and one small script, no matter how many elements use it, and it works on server-rendered markup because the attribute is just HTML.
The honest catch to name up front: because the hidden state is the default, an element stays invisible if that script never runs — a hard-JS-disabled visitor, or a hydration error. For content that must be readable no matter what, don't gate its visibility on JavaScript: either reserve this pattern for genuinely decorative motion, or flip the logic so elements are visible by default and only hide once a script has confirmed it's running (for instance, set a marker attribute on <html> from that same script and scope the hiding rule behind it). It's a small change, and which way you want it is a real call worth making on purpose rather than by accident.
This is the "more advanced" rung, but notice what it isn't: it isn't a keyframe, and it isn't React state threaded through a tree. It's the same two-state transition from Example 1, with the trigger moved from a browser pseudo-class to an attribute you flip yourself. Same lightest-tool instinct, one rung up.
Sharing keyframe definitions
Because every keyframe has to be a top-level export of a .css.ts file, you can't build one inside a component function at render time — that call produces nothing. What you can do is keep a private helper in the file and export its result, which is the clean way to stamp out a family of related animations:
import { keyframes } from "@salty-css/react/keyframes";
// Private to this file — never exported, only called at the top level.
const buildPulse = (scale: number) =>
keyframes({
animationName: `pulse-${String(scale).replace(".", "_")}`,
params: { duration: "1s", iterationCount: "infinite" },
"0%": { transform: "scale(1)" },
"50%": { transform: `scale(${scale})` },
"100%": { transform: "scale(1)" },
});
// These exports are concrete keyframe values, so the build picks them up.
export const pulseSmall = buildPulse(1.05);
export const pulseLarge = buildPulse(1.2);If you only need a couple of variations of the same animation, prefer overriding params at the call site instead — it reuses one @keyframes rule and emits less CSS than several near-identical definitions.