Seed First Project
The blank panel steps aside and a starter project builds itself in its place, task by task.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Seed First Project
*
* An empty workspace fills itself. The blank panel steps aside, a
* starter project takes its place, and its tasks arrive in the order
* they would have been written — so the workspace looks created rather
* than merely loaded.
*
* Self-contained: depends only on `react` and `motion`. Neutrals are
* mixed from the inherited text color, so it reads on light and dark
* pages alike. Works with zero props; tune via `variant`, `project`,
* `tasks`, `accent`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type SeedTask = {
label: string;
meta: string;
};
export type EmptyWorkspaceSeedProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Name of the project that gets created. */
project?: string;
/** Your own starter tasks. The embedded sample is used when omitted. */
tasks?: SeedTask[];
/** Label of the button on the empty state. */
actionLabel?: string;
/** Project swatch and button color. */
accent?: string;
/** Fires when the starter project is created. */
onSeed?: () => void;
};
type VariantConfig = {
/** px each arriving element travels. */
rise: number;
/** Seconds between one task landing and the next starting. */
stagger: number;
/** Seconds the empty panel takes to leave. */
exitSeconds: number;
cardSpring: { type: "spring"; stiffness: number; damping: number };
taskSeconds: number;
};
// Quality rule: damping ratios (damping / 2√stiffness) stay at or above
// 0.8, and the rows are text, so they travel on tweens. Variants change
// the pace and the distance, never the bounce.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Barely there. For a workspace that seeds itself without being asked.
subtle: {
rise: 6,
stagger: 0.045,
exitSeconds: 0.14,
cardSpring: { type: "spring", stiffness: 520, damping: 46 },
taskSeconds: 0.22,
},
// A clear sequence of things being made. All-purpose.
default: {
rise: 10,
stagger: 0.07,
exitSeconds: 0.18,
cardSpring: { type: "spring", stiffness: 420, damping: 40 },
taskSeconds: 0.28,
},
// A longer build, for the one time a new account is set up.
playful: {
rise: 14,
stagger: 0.1,
exitSeconds: 0.22,
cardSpring: { type: "spring", stiffness: 340, damping: 33 },
taskSeconds: 0.32,
},
};
const SAMPLE_TASKS: SeedTask[] = [
{ label: "Write the project brief", meta: "Today" },
{ label: "Collect reference links", meta: "Thu" },
{ label: "Share with the team", meta: "Fri" },
];
/** Fixed so the panel cannot resize as its contents are replaced — a
* frame that jumps mid-swap makes both states read as sloppy. */
const STAGE_HEIGHT = 214;
/** Theme-adaptive neutral: mixing the text color in scope with
* `transparent` lands correctly on light and dark surfaces alike. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
export default function EmptyWorkspaceSeed({
variant = "default",
project = "Launch plan",
tasks = SAMPLE_TASKS,
actionLabel = "Create starter project",
accent = "#5B5BD6",
onSeed,
}: EmptyWorkspaceSeedProps) {
const [seeded, setSeeded] = useState(false);
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
// Reduced motion: everything still arrives in order, it just arrives
// without travelling. The sequence is the information.
const rise = reduceMotion ? 0 : cfg.rise;
const seed = () => {
setSeeded(true);
onSeed?.();
};
return (
<div
style={{
width: 320,
boxSizing: "border-box",
padding: 16,
borderRadius: 18,
border: `1px solid ${tone(12)}`,
background: tone(5),
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: 12,
}}
>
<span style={{ fontSize: 13, fontWeight: 650 }}>Projects</span>
<button
type="button"
onClick={() => setSeeded(false)}
disabled={!seeded}
style={{
padding: "4px 9px",
fontSize: 11,
fontWeight: 600,
fontFamily: "inherit",
color: "inherit",
background: "transparent",
border: `1px solid ${tone(14)}`,
borderRadius: 7,
opacity: seeded ? 0.6 : 0,
cursor: seeded ? "pointer" : "default",
}}
>
Empty it
</button>
</div>
<div style={{ position: "relative", height: STAGE_HEIGHT }}>
<AnimatePresence initial={false}>
{!seeded && (
<motion.div
key="empty"
initial={false}
exit={{
opacity: 0,
y: -rise * 0.6,
transition: { duration: cfg.exitSeconds, ease: "easeIn" },
}}
style={{
position: "absolute",
inset: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 10,
padding: 16,
boxSizing: "border-box",
borderRadius: 14,
border: `1px dashed ${tone(20)}`,
background: tone(3),
textAlign: "center",
}}
>
<EmptyMark />
<div style={{ fontSize: 13, fontWeight: 600 }}>Nothing here yet</div>
<p
style={{
margin: 0,
maxWidth: 210,
fontSize: 11.5,
lineHeight: 1.55,
opacity: 0.55,
}}
>
Start from a plan we prepared and fill in the details later.
</p>
<button
type="button"
onClick={seed}
style={{
marginTop: 2,
padding: "9px 14px",
fontSize: 12.5,
fontWeight: 650,
fontFamily: "inherit",
color: "#ffffff",
background: accent,
border: "none",
borderRadius: 10,
cursor: "pointer",
}}
>
{actionLabel}
</button>
</motion.div>
)}
</AnimatePresence>
{seeded && (
<div style={{ position: "absolute", inset: 0 }}>
<motion.div
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: rise }}
animate={{ opacity: 1, y: 0 }}
transition={
reduceMotion
? { duration: 0.2, ease: "easeOut" }
: { ...cfg.cardSpring, delay: cfg.exitSeconds * 0.5 }
}
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "10px 12px",
borderRadius: 12,
border: `1px solid ${tone(12)}`,
background: tone(7),
}}
>
<span
style={{
width: 22,
height: 22,
borderRadius: 7,
display: "grid",
placeItems: "center",
background: accent,
}}
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<path
d="M1.6 3.4a1.4 1.4 0 0 1 1.4-1.4h1.7l1 1.3h3.7a1.4 1.4 0 0 1 1.4 1.4v3.9a1.4 1.4 0 0 1-1.4 1.4H3a1.4 1.4 0 0 1-1.4-1.4z"
stroke="#ffffff"
strokeWidth="1.2"
strokeLinejoin="round"
/>
</svg>
</span>
<span style={{ fontSize: 13, fontWeight: 650 }}>{project}</span>
<span style={{ marginLeft: "auto", fontSize: 11, opacity: 0.45 }}>
Just now
</span>
</motion.div>
<ul style={{ listStyle: "none", margin: "10px 0 0", padding: 0 }}>
{tasks.map((task, index) => (
<motion.li
key={task.label}
// Rows are text, so they translate and fade only —
// never scale, never rebound.
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: rise }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: reduceMotion ? 0.18 : cfg.taskSeconds,
delay:
(reduceMotion ? 0.04 : cfg.stagger) * index +
(reduceMotion ? 0.06 : cfg.exitSeconds + 0.1),
ease: "easeOut",
}}
style={{
display: "flex",
alignItems: "center",
gap: 9,
padding: "9px 2px",
borderTop: `1px solid ${tone(8)}`,
fontSize: 12.5,
}}
>
<span
style={{
width: 13,
height: 13,
borderRadius: 999,
border: `1.4px solid ${tone(28)}`,
}}
/>
{task.label}
<span style={{ marginLeft: "auto", fontSize: 11, opacity: 0.45 }}>
{task.meta}
</span>
</motion.li>
))}
</ul>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 0.5 }}
transition={{
duration: 0.24,
delay: reduceMotion
? 0.2
: cfg.exitSeconds + 0.1 + cfg.stagger * tasks.length + 0.1,
ease: "easeOut",
}}
style={{ marginTop: 10, fontSize: 11, fontWeight: 600 }}
>
{`${tasks.length} tasks added · edit anything`}
</motion.div>
</div>
)}
</div>
</div>
);
}
/** Outline of a stack of sheets — an inline SVG so the file stays a
* single copyable unit with no asset to fetch. */
function EmptyMark() {
return (
<svg width="42" height="42" viewBox="0 0 42 42" fill="none" aria-hidden>
<rect
x="9.5"
y="6.5"
width="23"
height="19"
rx="4"
stroke="currentColor"
strokeOpacity="0.32"
strokeWidth="1.6"
/>
<rect
x="6.5"
y="13.5"
width="29"
height="21"
rx="5"
stroke="currentColor"
strokeOpacity="0.5"
strokeWidth="1.6"
/>
<path
d="M12.5 21h11M12.5 26h17"
stroke="currentColor"
strokeOpacity="0.32"
strokeWidth="1.6"
strokeLinecap="round"
/>
</svg>
);
}About this pattern
The first thing a new account needs is something to look at. Pressing the empty state's button sends the blank panel up and out on a short eased exit, then the project card lands on a spring and its tasks follow in the order they would have been written — a build, not a page load, which is what makes a seeded workspace feel authored rather than faked. The frame around both states has a fixed height so nothing jumps as one replaces the other, and every task row translates and fades only, because rows are text. Reduced motion keeps the order of arrival and drops the travel.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Onboarding flow
An empty page replaced by a template whose blocks arrive in reading order.
Related patterns
- Invite Team SendEach address leaves the compose box and travels into a waiting seat, then marks itself sent.
- Sample Data PopulateExample figures sweep into an empty report so a new account can see what the product does before it has data of its own.
- Goal PickerChosen goal tiles paint themselves and take a mark, and the continue action rises the moment the first one is picked.