Persona Select
Picking a role lifts that card while the others step back, and a line below says what the answer changed.
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 · Persona Select
*
* Choosing a role lifts that card off the stack while the others recede,
* a mark settles into its corner, and a line appears underneath saying
* what the answer changed.
*
* 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`, `options`,
* `accent`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type PersonaOption = {
id: string;
label: string;
detail: string;
/** Which workspace the choice sets up. */
outcome: string;
};
export type PersonaSelectCardProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
question?: string;
hint?: string;
/** Your own roles. The embedded sample is used when omitted. */
options?: PersonaOption[];
/** Selection color. */
accent?: string;
/** Fires with the id of the role that was picked. */
onSelect?: (id: string) => void;
/** Fires when the choice is confirmed. */
onContinue?: (id: string) => void;
};
type VariantConfig = {
/** px the chosen card lifts. */
lift: number;
/** Opacity the unchosen cards fall back to. */
recede: number;
cardSpring: { type: "spring"; stiffness: number; damping: number };
/** Scale the mark grows from — a shape, so it may scale. */
markFrom: number;
markSpring: { type: "spring"; stiffness: number; damping: number };
/** px the follow-up line rises. */
footerRise: number;
};
// Quality rule: damping ratios (damping / 2√stiffness) stay at or above
// 0.8. These cards carry text, so they lift and fade — they never scale
// and never rock. Variants change lift and pace, never bounce.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// A hairline of lift. For a question asked mid-flow.
subtle: {
lift: 2,
recede: 0.55,
cardSpring: { type: "spring", stiffness: 540, damping: 46 },
markFrom: 0.7,
markSpring: { type: "spring", stiffness: 560, damping: 40 },
footerRise: 6,
},
// Enough separation to see the stack reorder itself. All-purpose.
default: {
lift: 4,
recede: 0.44,
cardSpring: { type: "spring", stiffness: 440, damping: 40 },
markFrom: 0.6,
markSpring: { type: "spring", stiffness: 480, damping: 36 },
footerRise: 10,
},
// A decisive lift, for the one question that shapes the whole account.
playful: {
lift: 6,
recede: 0.36,
cardSpring: { type: "spring", stiffness: 360, damping: 34 },
markFrom: 0.5,
markSpring: { type: "spring", stiffness: 400, damping: 33 },
footerRise: 14,
},
};
const SAMPLE_OPTIONS: PersonaOption[] = [
{
id: "design",
label: "Design",
detail: "Boards, files and review rounds",
outcome: "a design workspace",
},
{
id: "engineering",
label: "Engineering",
detail: "Issues, branches and releases",
outcome: "an engineering workspace",
},
{
id: "operations",
label: "Operations",
detail: "Schedules, vendors and budgets",
outcome: "an operations workspace",
},
];
/** 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 PersonaSelectCard({
variant = "default",
question = "What do you work on?",
hint = "We use this to lay out your first workspace. It can be changed later.",
options = SAMPLE_OPTIONS,
accent = "#5B5BD6",
onSelect,
onContinue,
}: PersonaSelectCardProps) {
const [picked, setPicked] = useState<string | null>(null);
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const chosen = options.find((option) => option.id === picked) ?? null;
const select = (id: string) => {
setPicked((current) => (current === id ? null : id));
onSelect?.(id);
};
return (
<div
style={{
width: 320,
boxSizing: "border-box",
padding: 18,
borderRadius: 18,
border: `1px solid ${tone(12)}`,
background: tone(5),
}}
>
<div style={{ fontSize: 15, fontWeight: 680, letterSpacing: -0.2 }}>
{question}
</div>
<p style={{ margin: "6px 0 14px", fontSize: 11.5, lineHeight: 1.5, opacity: 0.55 }}>
{hint}
</p>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{options.map((option) => {
const isPicked = option.id === picked;
const dimmed = picked !== null && !isPicked;
return (
<motion.button
key={option.id}
type="button"
onClick={() => select(option.id)}
aria-pressed={isPicked}
// The chosen card lifts and the rest step back. Opacity and
// a few pixels of travel only: these cards are text, and
// scaling a label to say "selected" is never worth it.
animate={{
y: reduceMotion ? 0 : isPicked ? -cfg.lift : dimmed ? 1 : 0,
opacity: dimmed ? cfg.recede : 1,
}}
transition={
reduceMotion
? { duration: 0.16, ease: "easeOut" }
: {
default: cfg.cardSpring,
opacity: { duration: 0.2, ease: "easeOut" },
}
}
style={{
position: "relative",
display: "flex",
alignItems: "center",
gap: 11,
width: "100%",
padding: "11px 12px",
fontFamily: "inherit",
color: "inherit",
textAlign: "left",
border: `1px solid ${tone(13)}`,
borderRadius: 13,
background: tone(4),
cursor: "pointer",
}}
>
{/* Selection is painted by fading in a layer, not by
animating a color: theme-adaptive neutrals are
color-mix() values and no engine can interpolate those. */}
<motion.span
aria-hidden
initial={false}
animate={{ opacity: isPicked ? 1 : 0 }}
transition={{ duration: 0.2, ease: "easeOut" }}
style={{
position: "absolute",
inset: -1,
borderRadius: 13,
border: `1px solid ${accent}`,
background: `color-mix(in srgb, ${accent} 12%, transparent)`,
pointerEvents: "none",
}}
/>
<span
style={{
position: "relative",
flex: "0 0 auto",
width: 30,
height: 30,
borderRadius: 9,
display: "grid",
placeItems: "center",
color: isPicked ? accent : "inherit",
opacity: isPicked ? 1 : 0.65,
background: tone(8),
}}
>
<RoleIcon id={option.id} />
</span>
<span style={{ position: "relative", minWidth: 0 }}>
<span style={{ display: "block", fontSize: 13, fontWeight: 650 }}>
{option.label}
</span>
<span
style={{
display: "block",
marginTop: 2,
fontSize: 11,
lineHeight: 1.4,
opacity: 0.55,
}}
>
{option.detail}
</span>
</span>
<span
aria-hidden
style={{
position: "relative",
marginLeft: "auto",
width: 20,
height: 20,
display: "grid",
placeItems: "center",
}}
>
<AnimatePresence initial={false}>
{isPicked && (
<motion.span
key="mark"
initial={
reduceMotion
? { opacity: 0 }
: { opacity: 0, scale: cfg.markFrom }
}
animate={{ opacity: 1, scale: 1 }}
exit={{
opacity: 0,
scale: reduceMotion ? 1 : cfg.markFrom,
transition: { duration: 0.12, ease: "easeIn" },
}}
transition={
reduceMotion
? { duration: 0.14, ease: "easeOut" }
: cfg.markSpring
}
style={{
width: 20,
height: 20,
borderRadius: 999,
display: "grid",
placeItems: "center",
background: accent,
}}
>
<svg width="11" height="11" viewBox="0 0 12 12" fill="none">
<path
d="M2.6 6.3 5 8.7l4.4-5"
stroke="#ffffff"
strokeWidth="1.9"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</motion.span>
)}
</AnimatePresence>
</span>
</motion.button>
);
})}
</div>
{/* The flow adapting to the answer is the payoff, so it gets its
own beat rather than appearing with the selection. */}
<div style={{ height: 46, marginTop: 12 }}>
<AnimatePresence initial={false}>
{chosen && (
<motion.div
key="footer"
initial={
reduceMotion ? { opacity: 0 } : { opacity: 0, y: cfg.footerRise }
}
animate={{ opacity: 1, y: 0 }}
exit={{
opacity: 0,
y: reduceMotion ? 0 : cfg.footerRise * 0.5,
transition: { duration: 0.14, ease: "easeIn" },
}}
transition={{
duration: reduceMotion ? 0.16 : 0.26,
delay: reduceMotion ? 0 : 0.08,
ease: "easeOut",
}}
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "0 2px",
}}
>
<span style={{ fontSize: 11.5, lineHeight: 1.4, opacity: 0.6 }}>
{`We'll set up ${chosen.outcome} to start.`}
</span>
<button
type="button"
onClick={() => onContinue?.(chosen.id)}
style={{
marginLeft: "auto",
flex: "0 0 auto",
padding: "9px 14px",
fontSize: 12.5,
fontWeight: 650,
fontFamily: "inherit",
color: "#ffffff",
background: accent,
border: "none",
borderRadius: 10,
cursor: "pointer",
}}
>
Continue
</button>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
);
}
/** Inline SVG role marks — no asset, no icon dependency. */
function RoleIcon({ id }: { id: string }) {
if (id === "engineering") {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
<path
d="M6 4.5 2.6 8 6 11.5M10 4.5 13.4 8 10 11.5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
if (id === "operations") {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
<rect x="2.4" y="3.4" width="11.2" height="10.2" rx="2.2" stroke="currentColor" strokeWidth="1.4" />
<path
d="M5.4 2.2v2.4M10.6 2.2v2.4M2.4 7h11.2"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
/>
</svg>
);
}
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
<path
d="M8 2.2 12.4 6 8 13.8 3.6 6z"
stroke="currentColor"
strokeWidth="1.4"
strokeLinejoin="round"
/>
<path d="M3.6 6h8.8" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
</svg>
);
}About this pattern
The role question every setup flow asks, answered visibly. The chosen card rises a few pixels on a spring and takes an accent border; the ones not chosen fall to under half opacity and settle a pixel lower, which is what makes the stack read as reordered rather than merely recolored. A mark scales into the corner — it is a shape, so it is allowed to — while the label beside it never moves or resizes. The payoff gets its own beat: a line underneath naming the workspace the answer will build, with the continue action beside it, on a reserved row so nothing below shifts when it appears.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Setup checklist
Choice rows that mark themselves and reveal a continue action once answered.
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.
- Tour Step HopThe tour tooltip travels to the next control instead of vanishing and popping up somewhere else.
- Tutorial Card PlayThe tutorial thumbnail grows into a player, and the controls only arrive once the frame has stopped moving.