Feedback & Data
SkeletonFlow
A light runs from one placeholder block to the next. When the data lands, the real content wipes in while the placeholder folds away, so the page height eases over instead of jumping.
Download
- React
- Tailwind CSS v4
- motion
components/ui/skeleton-flow.tsx
"use client";
import type { CSSProperties, ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
const SHAPES = {
text: { grid: "grid gap-2.5", bones: ["h-3.5 w-full rounded", "h-3.5 w-11/12 rounded", "h-3.5 w-3/5 rounded"] },
list: { grid: "grid grid-cols-[2.5rem_1fr] items-center gap-x-3 gap-y-2", bones: ["row-span-2 size-10 rounded-full", "h-3.5 w-2/5 self-end rounded", "h-3 w-4/5 self-start rounded"] },
card: { grid: "grid gap-3", bones: ["h-28 w-full rounded-xl", "h-4 w-3/5 rounded", "h-3 w-full rounded", "h-3 w-4/5 rounded"] },
};
const WIPE = "linear-gradient(100deg, #000 calc(var(--sf-wipe) - 30%), transparent var(--sf-wipe))";
type SkeletonFlowProps = {
/** True shows the placeholder, false shows the children. */
loading: boolean;
/** The real content. */
children: ReactNode;
/** Placeholder layout. Pick the one closest to the content. */
shape?: keyof typeof SHAPES;
/** How many times the shape repeats. */
count?: number;
/** Read by screen readers while loading. */
label?: string;
/** Colour of the light that runs through the blocks. */
accent?: string;
/** Replaces the block look. */
className?: string;
};
/* SkeletonFlow: a light runs from one placeholder block to the next, then the real content wipes in as the placeholder folds away. */
export function SkeletonFlow({ loading, children, shape = "text", count = 1, label = "Loading", accent = "#a78bfa", className = "bg-white/10" }: SkeletonFlowProps) {
const reduce = useReducedMotion();
const { grid, bones } = SHAPES[shape];
const move = reduce ? { duration: 0 } : { duration: 0.5, ease: [0.16, 1, 0.3, 1] as const };
return (
<div aria-busy={loading} style={{ "--sf": accent } as CSSProperties} className="w-full">
<span role="status" className="sr-only">
{loading ? label : ""}
</span>
<AnimatePresence initial={false}>
{loading ? (
<motion.div
key="bones"
aria-hidden
className="grid gap-5 overflow-hidden"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={move}
>
{Array.from({ length: count }, (_, row) => (
<div key={row} className={grid}>
{bones.map((bone, i) => (
<div key={bone} className={`relative overflow-hidden ${bone} ${className}`}>
{!reduce && (
<motion.span
className="absolute inset-0 bg-linear-to-r from-transparent via-(--sf)/30 to-transparent"
initial={{ x: "-100%" }}
animate={{ x: "100%" }}
transition={{ duration: 1.1, ease: "easeInOut", repeat: Infinity, repeatDelay: 0.5, delay: (row * bones.length + i) * 0.08 }}
/>
)}
</div>
))}
</div>
))}
</motion.div>
) : (
<motion.div
key="content"
style={reduce ? undefined : { maskImage: WIPE, WebkitMaskImage: WIPE }}
initial={{ height: 0, opacity: 0, overflow: "hidden", "--sf-wipe": "-30%" }}
animate={{ height: "auto", opacity: 1, "--sf-wipe": "130%", transitionEnd: { overflow: "visible", maskImage: "none", WebkitMaskImage: "none" } }}
exit={{ height: 0, opacity: 0, overflow: "hidden" }}
transition={move}
>
{children}
</motion.div>
)}
</AnimatePresence>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/skeleton-flow.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/skeleton-flow-demo.tsx
"use client"; import { useEffect, useState } from "react"; import { SkeletonFlow } from "@/components/ui/skeleton-flow"; const PEOPLE = [ { name: "Mira Chen", role: "Product designer" }, { name: "Arun Kumar", role: "Frontend engineer" }, ]; export default function SkeletonFlowExample() { const [loading, setLoading] = useState(true); // Fake fetch: loads, rests on the content, then starts over. useEffect(() => { const id = setTimeout(() => setLoading(!loading), loading ? 2600 : 2200); return () => clearTimeout(id); }, [loading]); return ( <div className="grid h-full place-items-center bg-[#0b0a14] p-6"> <div className="w-full max-w-xs rounded-2xl border border-white/10 bg-white/5 p-5"> <SkeletonFlow loading={loading} shape="list" count={2}> <ul className="grid gap-5"> {PEOPLE.map((person) => ( <li key={person.name} className="flex items-center gap-3"> <span aria-hidden className="grid size-10 place-items-center rounded-full bg-[#a78bfa] text-sm font-semibold text-[#101014]"> {person.name[0]} </span> <span> <span className="block text-sm font-medium text-white">{person.name}</span> <span className="block text-xs text-white/60">{person.role}</span> </span> </li> ))} </ul> </SkeletonFlow> </div> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| loading | boolean | — | True shows the placeholder, false shows the children. |
| children | ReactNode | — | The real content. |
| shape | "text" | "list" | "card" | "text" | Placeholder layout. Pick the one closest to the content. |
| count | number | 1 | How many times the shape repeats. |
| label | string | "Loading" | Read by screen readers while loading. |
| accent | string | "#a78bfa" | Colour of the light that runs through the blocks. |
| className | string | "bg-white/10" | Replaces the block look. |