All patterns

Role Badge Assign

Granting a role slides the selection pill and settles a new badge onto the member row.

authenticationminimalpremiuminteraction · finite · intermediate · ~0.5s
Interactive · click to play
Variant

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.

321 lines · react + motion only
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.

Team member permission changeAdmin console role assignmentAccess level pickerWorkspace member management

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Ridgeline
    Members
    General
    Billing
    Security
    Integrations
    MembersNew
    Nils Bergströmnils@ridgeline.coAdmin
    Priya Ramanpriya@ridgeline.coMember
    Marcus Bellmarcus@ridgeline.coMember
    Dana Whitfielddana@ridgeline.coViewer
    Team members

    Access levels change inline on the row for the person they apply to.

Related patterns