Create First Item
An outlined row fills its slots from the left to show the shape of the thing you have not made yet.
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 { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Create First Item
*
* An empty list explains itself by showing the shape of what belongs in
* it. A dashed row arrives, its slots fill from the left as if being
* written, and the invitation follows. Accept it and the same row is
* replaced in place by a real one — the preview and the result occupy
* the same cell, so nothing on the page shifts at the hand-off.
*
* 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.
* Requires the automatic JSX runtime (default since React 17).
*/
export type CreateFirstItemProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Sentence under the outlined row. */
hint?: string;
/** Label of the primary action. */
actionLabel?: string;
/** Title of the row created when the action is pressed. */
itemTitle?: string;
/** Secondary line of that row. */
itemMeta?: string;
/** Button and swatch color. A literal brand color, not a surface. */
accent?: string;
/** Fires when the action is pressed. */
onCreate?: () => void;
/** Block width — px number or any CSS length. */
width?: number | string;
};
type VariantConfig = {
/** px the outlined row and the copy travel on their way in. */
rise: number;
/** Seconds a slot bar takes to grow out to full width. */
barSeconds: number;
/** Seconds between one bar starting and the next. */
stagger: number;
fadeSeconds: number;
/** Seconds the preview takes to hand over to the real row. */
swapSeconds: number;
popSpring: { type: "spring"; stiffness: number; damping: number };
};
// Quality rule: the sprung elements are glyphs, never text, and every
// spring sits above 0.89 damping ratio (damping / 2√stiffness) so each
// lands with one settle. Bars grow on a tween because a growing bar that
// overshoots reads as a bug.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// A quiet suggestion, for a list that is empty only briefly.
subtle: {
rise: 5,
barSeconds: 0.26,
stagger: 0.05,
fadeSeconds: 0.22,
swapSeconds: 0.2,
popSpring: { type: "spring", stiffness: 520, damping: 46 },
},
// Clearly a demonstration of what goes here. All-purpose.
default: {
rise: 9,
barSeconds: 0.34,
stagger: 0.08,
fadeSeconds: 0.28,
swapSeconds: 0.26,
popSpring: { type: "spring", stiffness: 420, damping: 40 },
},
// The full walkthrough, for a first-run screen someone sees once.
playful: {
rise: 13,
barSeconds: 0.42,
stagger: 0.11,
fadeSeconds: 0.32,
swapSeconds: 0.3,
popSpring: { type: "spring", stiffness: 340, damping: 33 },
},
};
/** Theme-adaptive neutral: mixing the inherited text color with
* `transparent` keeps the outlined row and its slots legible on light
* and dark pages alike. The accent stays literal. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
export default function CreateFirstItem({
variant = "default",
hint = "Your first document will look like this",
actionLabel = "New document",
itemTitle = "Weekly status",
itemMeta = "Draft · edited just now",
accent = "#5B5BD6",
onCreate,
width = 320,
}: CreateFirstItemProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const [created, setCreated] = useState(false);
// Reduced motion keeps the whole sequence — outline, slots, invitation
// — and drops only the travel and the growth.
const rise = reduceMotion ? 0 : cfg.rise;
const swap = { duration: cfg.swapSeconds, ease: "easeOut" as const };
const create = () => {
setCreated(true);
onCreate?.();
};
return (
<div
style={{
width,
boxSizing: "border-box",
display: "flex",
flexDirection: "column",
alignItems: "center",
padding: "22px 20px 20px",
}}
>
{/* Preview and result share one grid cell, so the row is already
its final size before anything is created. */}
<motion.div
initial={{ opacity: 0, y: rise }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: cfg.fadeSeconds, ease: "easeOut" }}
style={{ display: "grid", width: "100%" }}
>
<motion.div
aria-hidden
animate={{ opacity: created ? 0 : 1 }}
transition={swap}
style={{
gridArea: "1 / 1",
display: "flex",
alignItems: "center",
gap: 12,
padding: "13px 14px",
borderRadius: 12,
border: `1px dashed ${tone(20)}`,
pointerEvents: "none",
}}
>
<motion.div
initial={{ scale: reduceMotion ? 1 : 0.6, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={
reduceMotion
? { duration: 0.2, ease: "easeOut" }
: { ...cfg.popSpring, delay: 0.1, opacity: { duration: 0.2 } }
}
style={{
width: 26,
height: 26,
borderRadius: 8,
background: tone(9),
flexShrink: 0,
}}
/>
<div style={{ flex: 1, display: "grid", gap: 7 }}>
{["62%", "38%"].map((barWidth, index) => (
<motion.div
key={barWidth}
initial={{ scaleX: reduceMotion ? 1 : 0, opacity: reduceMotion ? 0 : 1 }}
animate={{ scaleX: 1, opacity: 1 }}
transition={{
duration: reduceMotion ? 0.2 : cfg.barSeconds,
ease: "easeOut",
delay: 0.16 + index * cfg.stagger,
}}
style={{
width: barWidth,
height: index === 0 ? 8 : 6,
borderRadius: 4,
background: tone(index === 0 ? 12 : 8),
transformOrigin: "left center",
}}
/>
))}
</div>
</motion.div>
<motion.div
initial={false}
animate={{ opacity: created ? 1 : 0, y: created ? 0 : rise * 0.4 }}
transition={swap}
style={{
gridArea: "1 / 1",
display: "flex",
alignItems: "center",
gap: 12,
padding: "13px 14px",
borderRadius: 12,
background: tone(5),
border: `1px solid ${tone(12)}`,
pointerEvents: created ? "auto" : "none",
}}
>
<div
style={{
width: 26,
height: 26,
borderRadius: 8,
background: accent,
display: "grid",
placeItems: "center",
flexShrink: 0,
}}
>
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" aria-hidden>
<path
d="M4 2.5h4l2.5 2.5v6.5H4z"
stroke="#fff"
strokeWidth="1.2"
strokeLinejoin="round"
/>
<path d="M8 2.5V5h2.5" stroke="#fff" strokeWidth="1.2" strokeLinejoin="round" />
</svg>
</div>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 620 }}>{itemTitle}</div>
<div style={{ fontSize: 11.5, opacity: 0.5, marginTop: 2 }}>{itemMeta}</div>
</div>
</motion.div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: rise }}
animate={{ opacity: created ? 0 : 0.52, y: 0 }}
transition={{
duration: cfg.fadeSeconds,
ease: "easeOut",
delay: created ? 0 : 0.34,
}}
style={{ fontSize: 12, marginTop: 12, textAlign: "center" }}
>
{hint}
</motion.div>
<motion.button
type="button"
onClick={create}
initial={{ opacity: 0, y: rise }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: cfg.fadeSeconds, ease: "easeOut", delay: 0.42 }}
style={{
font: "inherit",
display: "inline-flex",
alignItems: "center",
gap: 7,
fontSize: 12.5,
fontWeight: 600,
color: "#fff",
background: accent,
border: "none",
borderRadius: 10,
padding: "9px 14px",
marginTop: 14,
cursor: "pointer",
}}
>
<motion.span
initial={{ scale: reduceMotion ? 1 : 0.5 }}
animate={{ scale: 1 }}
transition={
reduceMotion
? { duration: 0.2 }
: { ...cfg.popSpring, delay: 0.5 }
}
style={{ display: "inline-flex", lineHeight: 0 }}
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden>
<path
d="M6 1.5v9M1.5 6h9"
stroke="#fff"
strokeWidth="1.6"
strokeLinecap="round"
/>
</svg>
</motion.span>
{actionLabel}
</motion.button>
</div>
);
}About this pattern
An empty list that only says "nothing here" leaves the reader to imagine what belongs in it. This one demonstrates instead: a dashed row arrives, its swatch settles and its slot bars grow out from the left as if the row were being written, and the invitation follows once the shape is legible. Pressing the action replaces the outline with a real row in the same grid cell, so the demonstration becomes the result without the list resizing under it. Reduced motion keeps the order — outline, slots, invitation — and drops the growth.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Issue tracker
A new team's issue list shows the outline of a row alongside the action that creates one.
Related patterns
- All Caught UpA bell settles, a small badge completes its mark in one stroke, and the list confirms you are current.
- Empty CartThe cart mark drops and settles once, then recently viewed items slide in as the way back to shopping.
- Error RecoveryA failed panel settles without alarm, and the retry glyph turns exactly once per attempt.