Template Gallery Pick
The chosen thumbnail grows in place into a preview of the workspace it would create.
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 · Template Gallery Pick
*
* Choosing a template expands its thumbnail into a preview of the
* workspace it will create. The artwork is one shared element that
* grows in place, so the thing being previewed is visibly the thing
* that was picked.
*
* Self-contained: depends only on `react` and `motion`. Works with zero
* props; tune via `variant`, `templates`, `accent`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type GalleryTemplate = {
id: string;
name: string;
summary: string;
detail: string;
/** Artwork motif: "board", "calendar" or "pipeline". */
art: "board" | "calendar" | "pipeline";
/** Hex tint for the thumbnail wash. */
tint: string;
};
export type TemplateGalleryPickProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Your own templates. The embedded sample is used when omitted. */
templates?: GalleryTemplate[];
/** Confirm button label inside the preview. */
useLabel?: string;
/** Confirm button color. */
accent?: string;
/** Fires when a template's preview is confirmed. */
onUse?: (id: string) => void;
};
type VariantConfig = {
expand: { type: "spring"; stiffness: number; damping: number };
/** Pause before the preview's text arrives, in seconds. */
contentDelay: number;
/** How far the preview's text rises into place, in px. */
rise: number;
};
// Quality rule: the frame may grow, the words may not. Every spring sits
// at or above a 0.8 damping ratio, and the copy inside the preview fades
// up at a constant size rather than riding the scale — text carried by a
// layout animation is text nobody can read while it moves.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Straight in, no settle. For a gallery of twenty templates.
subtle: {
expand: { type: "spring", stiffness: 630, damping: 52 },
contentDelay: 0.05,
rise: 2,
},
// One soft landing. The all-purpose setting.
default: {
expand: { type: "spring", stiffness: 360, damping: 36 },
contentDelay: 0.1,
rise: 7,
},
// A slower, weightier expansion for a hero picker on its own screen.
playful: {
expand: { type: "spring", stiffness: 240, damping: 27 },
contentDelay: 0.19,
rise: 14,
},
};
/** Neutral surfaces are mixed from the inherited text color, so the card
* reads correctly on a light page and on a dark one. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
/** The tint is a brand color, not a surface, so it stays literal — it is
* only ever used at low strength as a wash behind the artwork. */
const wash = (tint: string, percent: number) =>
`color-mix(in srgb, ${tint} ${percent}%, transparent)`;
const SAMPLE_TEMPLATES: GalleryTemplate[] = [
{
id: "tracker",
name: "Project tracker",
summary: "3 boards · 24 cards",
detail:
"Boards for planned, active and shipped work, with a weekly review view already set up.",
art: "board",
tint: "#5B5BD6",
},
{
id: "calendar",
name: "Content calendar",
summary: "Month view · 12 slots",
detail:
"A month grid with draft, review and publish states, plus a backlog for unscheduled ideas.",
art: "calendar",
tint: "#2F8F9D",
},
{
id: "pipeline",
name: "Sales pipeline",
summary: "5 stages · 40 deals",
detail:
"Stages from first contact to signed, with per-stage value roll-ups on the summary row.",
art: "pipeline",
tint: "#B07A3E",
},
];
/** Thumbnail and preview share an aspect ratio, so the shared frame
* scales uniformly — a frame that changes shape mid-flight distorts
* everything drawn inside it. */
const THUMB_WIDTH = 88;
const THUMB_HEIGHT = 30;
const PREVIEW_HEIGHT = 97;
const STAGE_HEIGHT = 258;
export default function TemplateGalleryPick({
variant = "default",
templates = SAMPLE_TEMPLATES,
useLabel = "Use this template",
accent = "#5B5BD6",
onUse,
}: TemplateGalleryPickProps) {
const [openId, setOpenId] = useState<string | null>(null);
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const open = templates.find((template) => template.id === openId) ?? null;
const morph = reduceMotion
? { duration: 0.2, ease: "easeOut" as const }
: cfg.expand;
return (
<div
style={{
position: "relative",
width: 320,
height: STAGE_HEIGHT,
padding: 18,
borderRadius: 18,
border: `1px solid ${tone(12)}`,
background: tone(6),
boxSizing: "border-box",
overflow: "hidden",
}}
>
<div style={{ fontSize: 15, fontWeight: 650 }}>Pick a starting point</div>
<p style={{ margin: "4px 0 12px", fontSize: 12, opacity: 0.55 }}>
Every template is fully editable once it is created.
</p>
<div style={{ display: "flex", gap: 8 }}>
{templates.map((template) => (
<button
key={template.id}
type="button"
onClick={() => setOpenId(template.id)}
style={{
flex: 1,
padding: 0,
textAlign: "left",
fontFamily: "inherit",
color: "inherit",
background: "transparent",
border: "none",
cursor: "pointer",
}}
>
<motion.span
// The frame is the shared element. Only the wash lives on
// it, so the expansion has nothing brittle to carry.
layoutId={reduceMotion ? undefined : `template-frame-${template.id}`}
transition={morph}
style={{
display: "block",
height: THUMB_HEIGHT,
borderRadius: 9,
border: `1px solid ${tone(12)}`,
background: `linear-gradient(135deg, ${wash(
template.tint,
24
)}, ${wash(template.tint, 8)})`,
overflow: "hidden",
}}
>
<TemplateArt art={template.art} tint={template.tint} />
</motion.span>
<span
style={{
display: "block",
marginTop: 6,
fontSize: 11.5,
fontWeight: 600,
}}
>
{template.name}
</span>
<span
style={{ display: "block", marginTop: 1, fontSize: 10.5, opacity: 0.45 }}
>
{template.summary}
</span>
</button>
))}
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginTop: 14,
padding: "9px 11px",
fontSize: 12,
borderRadius: 10,
border: `1px dashed ${tone(14)}`,
opacity: 0.6,
}}
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden>
<path
d="M8 3.6v8.8M3.6 8h8.8"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
Start from an empty workspace
</div>
<div
style={{
marginTop: 12,
fontSize: 11.5,
opacity: 0.4,
}}
>
Browse all 24 templates
</div>
<AnimatePresence>
{open && (
<motion.div
key="preview"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: reduceMotion ? 0.16 : 0.2, ease: "easeOut" }}
style={{
position: "absolute",
inset: 0,
padding: 18,
boxSizing: "border-box",
// Opaque, because the gallery is still mounted underneath:
// `Canvas`/`CanvasText` are the CSS system colors for page
// background and text, so the preview lands light in a
// light app and dark in a dark one.
background: "Canvas",
color: "CanvasText",
}}
>
<motion.div
layoutId={reduceMotion ? undefined : `template-frame-${open.id}`}
transition={morph}
style={{
height: PREVIEW_HEIGHT,
borderRadius: 12,
border: `1px solid ${tone(12)}`,
background: `linear-gradient(135deg, ${wash(
open.tint,
24
)}, ${wash(open.tint, 8)})`,
overflow: "hidden",
}}
>
<TemplateArt art={open.art} tint={open.tint} />
</motion.div>
<motion.div
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: cfg.rise }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.28,
delay: cfg.contentDelay,
ease: "easeOut",
}}
style={{ marginTop: 11 }}
>
<div
style={{
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
gap: 8,
}}
>
<span style={{ fontSize: 14.5, fontWeight: 650 }}>{open.name}</span>
<span style={{ fontSize: 11, opacity: 0.45 }}>{open.summary}</span>
</div>
<p
style={{
margin: "5px 0 0",
fontSize: 12,
lineHeight: 1.5,
opacity: 0.6,
}}
>
{open.detail}
</p>
</motion.div>
<motion.div
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: cfg.rise }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.28,
delay: cfg.contentDelay + 0.06,
ease: "easeOut",
}}
style={{ display: "flex", gap: 8, marginTop: 12 }}
>
<button
type="button"
onClick={() => setOpenId(null)}
style={{
padding: "9px 13px",
fontSize: 12.5,
fontWeight: 600,
fontFamily: "inherit",
color: "inherit",
background: "transparent",
border: `1px solid ${tone(18)}`,
borderRadius: 9,
cursor: "pointer",
}}
>
Back
</button>
<button
type="button"
onClick={() => {
onUse?.(open.id);
setOpenId(null);
}}
style={{
flex: 1,
padding: "9px 13px",
fontSize: 12.5,
fontWeight: 600,
fontFamily: "inherit",
color: "#ffffff",
background: accent,
border: "none",
borderRadius: 9,
cursor: "pointer",
}}
>
{useLabel}
</button>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
/** Abstract artwork, drawn as rectangles so it scales with the frame
* without a single asset request. */
function TemplateArt({
art,
tint,
}: {
art: GalleryTemplate["art"];
tint: string;
}) {
return (
<svg
viewBox={`0 0 ${THUMB_WIDTH} ${THUMB_HEIGHT}`}
preserveAspectRatio="none"
width="100%"
height="100%"
aria-hidden
style={{ display: "block" }}
>
{art === "board" &&
[6, 32, 58].map((x, column) => (
<g key={x}>
<rect
x={x}
y={4}
width={24}
height={2.5}
rx={1.25}
fill={tint}
opacity={0.55}
/>
{[0, 1, 2].slice(0, 3 - column).map((row) => (
<rect
key={row}
x={x}
y={9 + row * 6.5}
width={24}
height={5}
rx={1.8}
fill="currentColor"
opacity={0.16}
/>
))}
</g>
))}
{art === "calendar" &&
[0, 1, 2].map((row) =>
[0, 1, 2, 3, 4, 5].map((column) => (
<rect
key={`${row}-${column}`}
x={6 + column * 13}
y={5 + row * 8}
width={10}
height={6}
rx={1.6}
fill={row === 1 && column < 3 ? tint : "currentColor"}
opacity={row === 1 && column < 3 ? 0.5 : 0.15}
/>
))
)}
{art === "pipeline" && (
<g>
{[8, 13, 18, 21, 24].map((height, index) => (
<rect
key={index}
x={6 + index * 16}
y={THUMB_HEIGHT - 3 - height}
width={11}
height={height}
rx={2}
fill={index === 4 ? tint : "currentColor"}
opacity={index === 4 ? 0.55 : 0.16}
/>
))}
</g>
)}
</svg>
);
}About this pattern
A starting-point chooser where picking and previewing are the same gesture. The thumbnail's frame is a shared element: Motion measures it in the strip and again in the preview and grows it between the two, so what is being described is visibly what was tapped. Thumbnail and preview are cut to the same aspect ratio, which keeps the scale uniform — a frame that changes shape mid-flight distorts everything drawn inside it. Only the wash and the artwork ride the expansion; the name, the summary and the buttons fade up afterwards at their final size, because text carried by a layout animation is text nobody can read while it moves. The artwork is inline SVG, so a gallery of templates costs no image requests.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Onboarding flow
A template tile opens into a full preview of the pages it would add.
Related patterns
- Name Your WorkspaceTyping a name rolls the monogram to its new initial and slides a fresh address under the header it will appear in.
- Tutorial Card PlayThe tutorial thumbnail grows into a player, and the controls only arrive once the frame has stopped moving.
- Value Prop CarouselIntro slides advance on their own, with the artwork crossfading a beat behind the copy.