basic stagger animation is easy: give item N a delay of N times some constant, and a list cascades in one row at a time. It gets less obvious the moment your content isn't a single list — our service pages show technology chips grouped into columns (Frontend, Backend, Tooling), and a naive per-item stagger across the whole page would either ignore the grouping entirely or force every column to wait for the ones before it to finish first.
delay:idx*0.08+i*0.05—twoindependentcounters,addedtogether,andthewholegridcascadesdiagonallyinsteadofcolumnbycolumn.
Try it
Frontend
- React
- Next.js
- TypeScript
Backend
- Postgres
- Prisma
- REST
Each column gets its own whileInView trigger with delay: idx * 0.08 — column 0 starts immediately, column 1 waits 80ms, and so on. Independently, each chip inside a column gets delay: i * 0.05, where i resets to zero at the start of every column. Combine them — idx * 0.08 + i * 0.05 — and the result isn't "finish column 1, then start column 2": it's a diagonal wave, where column 2's first chip can appear before column 1's third chip does, because 0.16 (column 2, chip 0) is less than 0.10 + 0.05 (column 1, chip 2's math, roughly).
tsx
{columns.map((col, idx) => ( <motion.div initial={{ opacity: 0, y: 20 }} whileInView={{ opacity: 1, y: 0 }} transition={{ delay: idx * 0.08 }} // column offset viewport={{ once: true }} > {col.items.map((item, i) => ( <motion.li initial={{ opacity: 0, y: 10 }} whileInView={{ opacity: 1, y: 0 }} transition={{ delay: idx * 0.08 + i * 0.05 }} // + chip offset viewport={{ once: true }} > {item} </motion.li> ))} </motion.div> ))}
Why not framer-motion's built-in staggerChildren
Framer Motion has a variants-based staggerChildren option that handles the single-axis case elegantly — parent orchestrates, children just declare a variant name. It works well when every child should follow the exact same fixed delay increment. It works less well the moment you have two independent groupings that should each contribute their own offset, because staggerChildren computes one delay per child based on its index in a flat list — it doesn't know about "column" as a concept. Two counters added by hand is a few more characters than a variants config, and it's the version that actually generalizes to a grid.
The hover lift is a separate, much smaller decision
Once a chip has arrived, hovering it lifts it half a pixel and brightens its border and text — a plain CSS transition, not Framer Motion at all, because it doesn't need spring physics or scroll awareness; it just needs to feel responsive on :hover, which transition-all duration-300 already does for free. Not every piece of motion on a page needs the same tool. The entrance needed viewport awareness and staggered timing, so it got a motion library. The hover state needed neither, so it got two Tailwind classes.