Role Badge Assign
Granting a role slides the selection pill and settles a new badge onto the member row.
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 { useId, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Role Badge Assign
*
* Changing a member's permissions is a consequential edit that usually
* looks like a dropdown closing. Here the selection pill slides to the
* new role and the badge on the member row is replaced in the same beat,
* with a single ring settling behind it — the row confirms the grant.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Neutrals mix from the inherited text color, so the row reads correctly
* on a light page and on a dark one.
* Works with zero props; tune via `variant`, `memberName`, `accent`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type RoleBadgeAssignProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Member being edited. */
memberName?: string;
/** Line under the name. */
memberEmail?: string;
/** Two letters on the member disc — never a photo. */
initials?: string;
/** Color of the granted badge and the selection pill. */
accent?: string;
/** Fires with the role key whenever a new role is granted. */
onRoleChange?: (role: string) => void;
};
type VariantConfig = {
/** Travel of the badge as it is replaced, in px. */
rise: number;
/** How far the confirming ring expands past the badge. */
ring: number;
pill: { type: "spring"; stiffness: number; damping: number };
badge: { type: "spring"; stiffness: number; damping: number };
};
// Quality rule: the badge is a word, so it travels and fades but never
// scales or rebounds — the only thing that changes size here is the ring,
// which has no text in it. Both springs sit above a 0.8 damping ratio.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Administrative and quiet. For permission tables edited in bulk.
subtle: {
rise: 5,
ring: 6,
pill: { type: "spring", stiffness: 620, damping: 44 },
badge: { type: "spring", stiffness: 560, damping: 44 },
},
// The all-purpose setting: the grant is visible without being loud.
default: {
rise: 9,
ring: 10,
pill: { type: "spring", stiffness: 480, damping: 40 },
badge: { type: "spring", stiffness: 440, damping: 38 },
},
// A wider ring and more travel, for a member screen where granting
// admin is a deliberate, rare act.
playful: {
rise: 13,
ring: 15,
pill: { type: "spring", stiffness: 380, damping: 34 },
badge: { type: "spring", stiffness: 360, damping: 32 },
},
};
/** Theme-adaptive neutral: `currentColor` is the inherited text color, so
* mixing it with `transparent` yields a surface, border or fill that is
* correctly toned on a light page and on a dark one. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const ROLES = [
{ key: "viewer", label: "Viewer", note: "Can read" },
{ key: "editor", label: "Editor", note: "Can edit" },
{ key: "admin", label: "Admin", note: "Full access" },
] as const;
export default function RoleBadgeAssign({
variant = "default",
memberName = "Priya Raman",
memberEmail = "priya@northwind.co",
initials = "PR",
accent = "#5B5BD6",
onRoleChange,
}: RoleBadgeAssignProps) {
const [role, setRole] = useState<string>("viewer");
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const uid = useId();
const current = ROLES.find((entry) => entry.key === role) ?? ROLES[0];
const granted = role === "admin";
const choose = (next: string) => {
if (next === role) return;
setRole(next);
onRoleChange?.(next);
};
// Reduced motion keeps every state change legible — the pill still
// marks the chosen role, the badge still swaps — and drops the slide,
// the travel and the ring.
const badgeMotion = reduceMotion
? {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
transition: { duration: 0.14, ease: "easeOut" as const },
}
: {
initial: { opacity: 0, y: cfg.rise },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -cfg.rise },
transition: {
...cfg.badge,
opacity: { duration: 0.16, ease: "easeOut" as const },
},
};
return (
<div
style={{
width: 320,
padding: 16,
borderRadius: 16,
background: tone(6),
color: "inherit",
border: `1px solid ${tone(12)}`,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 11 }}>
{/* Member mark: initials on a colored disc. The component ships
with no asset, so there is never an image to load or fail. */}
<span
aria-hidden
style={{
display: "grid",
placeItems: "center",
width: 38,
height: 38,
flexShrink: 0,
borderRadius: 999,
background: tone(12),
color: "inherit",
fontSize: 13,
fontWeight: 700,
letterSpacing: 0.3,
}}
>
{initials}
</span>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ fontSize: 13.5, fontWeight: 650 }}>{memberName}</div>
<div style={{ fontSize: 11.5, opacity: 0.5, marginTop: 2 }}>{memberEmail}</div>
</div>
{/* The badge slot is a fixed box so the row never reflows while
one word is replaced by another. */}
<div
style={{
position: "relative",
width: 84,
height: 26,
flexShrink: 0,
display: "grid",
placeItems: "center",
}}
>
{/* One ring, once, behind the badge — the confirming beat. It
contains no text, so it is free to scale. */}
<AnimatePresence>
{!reduceMotion && granted && (
<motion.span
key={`ring-${role}`}
aria-hidden
initial={{ opacity: 0.5, scale: 1 }}
animate={{ opacity: 0, scale: 1 + cfg.ring / 40 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.5, ease: "easeOut" }}
style={{
position: "absolute",
inset: 0,
borderRadius: 999,
border: `1.5px solid ${accent}`,
}}
/>
)}
</AnimatePresence>
<AnimatePresence mode="wait" initial={false}>
<motion.span
key={role}
{...badgeMotion}
style={{
display: "inline-flex",
alignItems: "center",
gap: 5,
padding: "5px 10px",
borderRadius: 999,
fontSize: 11.5,
fontWeight: 650,
whiteSpace: "nowrap",
background: granted ? accent : tone(10),
color: granted ? "#FFFFFF" : "inherit",
border: granted ? "none" : `1px solid ${tone(12)}`,
}}
>
<RoleIcon role={current.key} />
{current.label}
</motion.span>
</AnimatePresence>
</div>
</div>
<div
style={{
display: "flex",
gap: 6,
marginTop: 14,
paddingTop: 13,
borderTop: `1px solid ${tone(10)}`,
}}
>
{ROLES.map((entry) => {
const selected = entry.key === role;
return (
<button
key={entry.key}
type="button"
aria-pressed={selected}
onClick={() => choose(entry.key)}
style={{
position: "relative",
flex: 1,
padding: "9px 6px",
borderRadius: 10,
border: `1px solid ${selected ? "transparent" : tone(12)}`,
background: "transparent",
color: "inherit",
fontFamily: "inherit",
cursor: "pointer",
textAlign: "center",
}}
>
{/* The selection pill is a single element that moves
between chips instead of three that fade — the eye
follows one object and the choice reads as a change of
place, not a change of colour. */}
{selected && (
<motion.span
aria-hidden
layoutId={`${uid}-role-pill`}
transition={reduceMotion ? { duration: 0 } : cfg.pill}
style={{
position: "absolute",
inset: 0,
borderRadius: 10,
background: tone(10),
border: `1px solid ${tone(16)}`,
}}
/>
)}
<span style={{ position: "relative", display: "block" }}>
<span style={{ fontSize: 12, fontWeight: 600 }}>{entry.label}</span>
<span
style={{
display: "block",
fontSize: 10.5,
opacity: 0.5,
marginTop: 2,
}}
>
{entry.note}
</span>
</span>
</button>
);
})}
</div>
</div>
);
}
function RoleIcon({ role }: { role: string }) {
const common = {
width: 12,
height: 12,
viewBox: "0 0 20 20",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.7,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
};
if (role === "admin") {
return (
<svg {...common} aria-hidden>
<path d="M10 3.4l5 1.8v4.2c0 2.9-2 5.2-5 6.2-3-1-5-3.3-5-6.2V5.2l5-1.8z" />
</svg>
);
}
if (role === "editor") {
return (
<svg {...common} aria-hidden>
<path d="M13.4 4.6l2 2L7.8 14.2l-2.8.8.8-2.8 7.6-7.6z" />
</svg>
);
}
return (
<svg {...common} aria-hidden>
<path d="M2.6 10S5.4 5.4 10 5.4 17.4 10 17.4 10 14.6 14.6 10 14.6 2.6 10 2.6 10z" />
<circle cx="10" cy="10" r="2.1" />
</svg>
);
}About this pattern
Permission changes are consequential edits that normally look like a dropdown closing. This makes the grant visible in the row it applies to: a single selection pill travels between the role chips, so the choice reads as one object moving rather than three colours changing, and the badge beside the member name is replaced in the same beat. The badge is a word, so it rises and fades but never scales or rebounds — the only element that changes size is a ring behind it, which contains no text and expands exactly once. The badge slot is a fixed box, so replacing one word with another never reflows the row.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Team members
Access levels change inline on the row for the person they apply to.
Related patterns
- Recovery Codes RevealBlurred backup codes sharpen across the grid once revealed, then copy in one press.
- Sign-in Form EntranceHeading, fields and button rise into place in one quick sequence as the screen opens.
- Permission Scope ListA consent screen reveals each requested scope in turn and keeps the approve button inert until they have all landed.