# Guild — Animated Section Block for React

> About & team section block: a mission statement with an oversized stat row rolling in on Tally digit drums, a team grid of seeded generative portraits that rest as an accent duotone and warm to full color on hover or focus with a slide-up bio panel, a Detent department filter that re-flows the grid with layout springs, and a numbered values list whose rules draw in.

- Page: https://crucibleui.com/components/sections/guild
- Category: [sections](https://crucibleui.com/components/sections)
- Tier: free (full source)
- Engine: motion
- npm dependencies: motion

## Install

```bash
npx shadcn@latest add @crucible/guild
```

Registry JSON: https://crucibleui.com/r/guild.json

Import from `@/components/crucible/guild`.

## Props (`GuildProps`)

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `eyebrow?` | `React.ReactNode` | `"About us"` | Small label above the mission statement. Pass `null` to hide. |
| `mission?` | `React.ReactNode` | — | The mission statement, rendered as the section's `<h2>`. Pass `null` to hide the whole mission part (statement + stats). |
| `missionBody?` | `React.ReactNode` | — | Supporting copy under the mission statement. Pass `null` to hide. |
| `stats?` | `GuildStat[]` | — | Oversized stat row under the mission. Numbers roll in on view. Pass `[]` to hide. |
| `teamTitle?` | `React.ReactNode` | `"The people behind the work"` | Heading of the team part. |
| `teamDescription?` | `React.ReactNode` | — | Copy under the team heading. Pass `null` to hide. |
| `people?` | `GuildPerson[]` | — | Team members. Pass `[]` to hide the team part. |
| `showFilter?` | `boolean` | `true` | Show the department filter (only rendered when there are 2+ teams). |
| `allLabel?` | `string` | `"Everyone"` | Label of the "show everyone" filter tab. |
| `filter?` | `string \| null` | — | Controlled filter — a team name, or `null` for everyone. |
| `defaultFilter?` | `string \| null` | `null (everyone)` | Initial filter when uncontrolled. |
| `onFilterChange?` | `(team: string \| null) => void` | — | Fires when the department filter changes (`null` = everyone). |
| `onPersonToggle?` | `(person: GuildPerson, open: boolean) => void` | — | Fires when a person's reveal panel is pinned open or closed by click, tap or keyboard. |
| `valuesTitle?` | `React.ReactNode` | `"What we hold to"` | Heading of the values part. |
| `values?` | `GuildValue[]` | — | Numbered values list. Pass `[]` to hide. |
| `accentColor?` | `string` | `"#fda4af"` | Portraits rest as a duotone in this hue and warm to full color on hover/focus; also tints numerals, rules and focus rings. |
| `ref?` | `React.Ref<GuildHandle>` | — | Imperative handle (`replay()`, `setFilter()`). React 19 ref-as-prop. |
| `className?` | `string` | — | Extra classes for the `<section>` root. |

Also accepts: `Omit<React.ComponentPropsWithoutRef<"section">, "children" | "title">`.

## Usage example

```tsx
"use client";

import * as React from "react";

import { Guild, type GuildHandle } from "@/components/crucible/guild";

export default function GuildDemo() {
  const ref = React.useRef<GuildHandle>(null);

  return (
    <div className="relative isolate flex h-[640px] w-full flex-col overflow-hidden rounded-xl bg-neutral-950">
      <div className="relative z-10 flex items-center justify-between gap-3 border-b border-white/[0.06] px-4 py-2.5 text-xs text-white/45">
        <span>Filter by team — hover a person.</span>
        <button
          type="button"
          onClick={() => ref.current?.replay()}
          className="rounded underline underline-offset-4 transition-colors hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60"
        >
          Replay
        </button>
      </div>
      <div className="relative z-10 min-h-0 flex-1 overflow-y-auto overscroll-contain">
        <Guild ref={ref} />
      </div>
    </div>
  );
}
```

## Accessibility & performance

Honors `prefers-reduced-motion` with a designed static frame or fade (never a blank box) and passes `className` through.

## Source (`guild.tsx`)

```tsx
"use client";

import { motion, useInView, type Transition, type Variants } from "motion/react";
import * as React from "react";

import { cn } from "@/lib/utils";
import { Detent } from "@/components/crucible/detent";
import { useReducedMotion } from "@/hooks/use-reduced-motion";
import { Tally } from "@/components/crucible/tally";

/* ------------------------------------------------------------------ types */

/** One oversized number in the mission stat row. */
export interface GuildStat {
  /** The number that rolls in on view. */
  value: number;
  /** Caption under the number, e.g. "Countries". */
  label: React.ReactNode;
  /** Value the digit drums start from. @default 0 */
  start?: number;
  /** Text before the number, e.g. "$". */
  prefix?: string;
  /** Text after the number, e.g. "+" or "%". Rendered in the accent color. */
  suffix?: string;
  /** Decimal places. @default 0 */
  decimals?: number;
  /** Extra Intl.NumberFormat options — e.g. `{ useGrouping: false }` for a year. */
  formatOptions?: Intl.NumberFormatOptions;
}

/** A link shown in a person's reveal panel. */
export interface GuildLink {
  /** Visible label, e.g. "Website", "Writing", "Email". */
  label: string;
  /** Destination. `mailto:` links get a mail glyph, everything else an arrow. */
  href: string;
}

/** A team member card. */
export interface GuildPerson {
  /** Full name. Also seeds the generative portrait — keep it unique. */
  name: string;
  /** Job title shown under the name. */
  role: string;
  /** Department used by the filter, e.g. "Design". */
  team: string;
  /** One-line bio revealed on hover / focus / tap. */
  bio?: string;
  /** Links revealed with the bio. */
  links?: GuildLink[];
  /** Optional photo URL. Omit for a seeded generative portrait with initials. */
  image?: string;
}

/** A numbered value in the values list. */
export interface GuildValue {
  /** Short value name. */
  title: React.ReactNode;
  /** One or two sentences expanding on it. */
  description?: React.ReactNode;
}

/** Imperative verbs exposed through the `ref` prop. */
export interface GuildHandle {
  /** Replay the intro: stats roll again, cards plate back in, rules redraw. */
  replay: () => void;
  /** Switch the department filter. Pass `null` for everyone. */
  setFilter: (team: string | null) => void;
}

export interface GuildProps extends Omit<React.ComponentPropsWithoutRef<"section">, "children" | "title"> {
  /** Small label above the mission statement. Pass `null` to hide. @default "About us" */
  eyebrow?: React.ReactNode;
  /**
   * The mission statement, rendered as the section's `<h2>`. Pass `null` to
   * hide the whole mission part (statement + stats).
   */
  mission?: React.ReactNode;
  /** Supporting copy under the mission statement. Pass `null` to hide. */
  missionBody?: React.ReactNode;
  /** Oversized stat row under the mission. Numbers roll in on view. Pass `[]` to hide. */
  stats?: GuildStat[];
  /** Heading of the team part. @default "The people behind the work" */
  teamTitle?: React.ReactNode;
  /** Copy under the team heading. Pass `null` to hide. */
  teamDescription?: React.ReactNode;
  /** Team members. Pass `[]` to hide the team part. */
  people?: GuildPerson[];
  /** Show the department filter (only rendered when there are 2+ teams). @default true */
  showFilter?: boolean;
  /** Label of the "show everyone" filter tab. @default "Everyone" */
  allLabel?: string;
  /** Controlled filter — a team name, or `null` for everyone. */
  filter?: string | null;
  /** Initial filter when uncontrolled. @default null (everyone) */
  defaultFilter?: string | null;
  /** Fires when the department filter changes (`null` = everyone). */
  onFilterChange?: (team: string | null) => void;
  /** Fires when a person's reveal panel is pinned open or closed by click, tap or keyboard. */
  onPersonToggle?: (person: GuildPerson, open: boolean) => void;
  /** Heading of the values part. @default "What we hold to" */
  valuesTitle?: React.ReactNode;
  /** Numbered values list. Pass `[]` to hide. */
  values?: GuildValue[];
  /**
   * Portraits rest as a duotone in this hue and warm to full color on
   * hover/focus; also tints numerals, rules and focus rings.
   * @default "#fda4af"
   */
  accentColor?: string;
  /** Imperative handle (`replay()`, `setFilter()`). React 19 ref-as-prop. */
  ref?: React.Ref<GuildHandle>;
  /** Extra classes for the `<section>` root. */
  className?: string;
}

/* --------------------------------------------------------------- defaults */

const DEFAULT_ACCENT = "#fda4af";
const ALL = "_all";
const EASE: [number, number, number, number] = [0.22, 1, 0.36, 1];
const SPRING: Transition = { type: "spring", stiffness: 260, damping: 30, mass: 0.9 };
const INSTANT: Transition = { duration: 0 };

const DEFAULT_MISSION =
  "We build calm, durable software for teams who would rather ship than babysit their tools.";
const DEFAULT_MISSION_BODY =
  "A small, distributed company — independent, profitable, and in no hurry to be anything else. We hire slowly, write things down, and keep the product honest.";

const DEFAULT_STATS: GuildStat[] = [
  { value: 2014, start: 1990, label: "Founded", formatOptions: { useGrouping: false } },
  { value: 48, label: "People on the team" },
  { value: 11, label: "Countries we work from" },
  { value: 96, suffix: "%", label: "Still here after two years" },
];

const DEFAULT_PEOPLE: GuildPerson[] = [
  {
    name: "Mara Okafor",
    role: "Co-founder & CEO",
    team: "Operations",
    bio: "Ran logistics for a rail freight network before deciding software could be quieter.",
    links: [
      { label: "Writing", href: "#" },
      { label: "Email", href: "mailto:mara@example.com" },
    ],
  },
  {
    name: "Tomas Lindqvist",
    role: "Co-founder & CTO",
    team: "Engineering",
    bio: "Compiler nerd. Still reviews every migration that touches billing.",
    links: [
      { label: "Website", href: "#" },
      { label: "Email", href: "mailto:tomas@example.com" },
    ],
  },
  {
    name: "Ines Carvalho",
    role: "Head of Design",
    team: "Design",
    bio: "Draws every flow on paper first; keeps a drawer of retired icons for luck.",
    links: [{ label: "Portfolio", href: "#" }],
  },
  {
    name: "Priya Raman",
    role: "Product Lead",
    team: "Product",
    bio: "Former support engineer who reads every ticket tagged “confusing”.",
    links: [
      { label: "Writing", href: "#" },
      { label: "Email", href: "mailto:priya@example.com" },
    ],
  },
  {
    name: "Kenji Arakawa",
    role: "Staff Engineer",
    team: "Engineering",
    bio: "Makes slow queries fast and fast queries boring. Bakes on Fridays.",
    links: [{ label: "Website", href: "#" }],
  },
  {
    name: "Leo Brandt",
    role: "Brand Designer",
    team: "Design",
    bio: "Type specimens, posters, and the occasional hand-lettered changelog.",
    links: [{ label: "Portfolio", href: "#" }],
  },
  {
    name: "Sofia Marchetti",
    role: "People & Culture",
    team: "Operations",
    bio: "Designed our async onboarding — new hires ship in their first week.",
    links: [{ label: "Email", href: "mailto:sofia@example.com" }],
  },
  {
    name: "Daniel Osei",
    role: "Platform Engineer",
    team: "Engineering",
    bio: "Keeps the deploy pipeline under four minutes and the pager quiet.",
    links: [{ label: "Website", href: "#" }],
  },
  {
    name: "Hana Kowalski",
    role: "Product Designer",
    team: "Design",
    bio: "Prototypes in code, tests with real customers, sweats the empty states.",
    links: [
      { label: "Portfolio", href: "#" },
      { label: "Email", href: "mailto:hana@example.com" },
    ],
  },
  {
    name: "Rafael Duarte",
    role: "Product Manager",
    team: "Product",
    bio: "Turns long customer calls into short, opinionated specs.",
    links: [{ label: "Writing", href: "#" }],
  },
  {
    name: "Amara Nwosu",
    role: "User Researcher",
    team: "Product",
    bio: "Has interviewed more than four hundred customers and kept every note.",
    links: [{ label: "Writing", href: "#" }],
  },
  {
    name: "Noor Haddad",
    role: "Finance & Ops",
    team: "Operations",
    bio: "Closes the books in two days and explains them in two paragraphs.",
    links: [{ label: "Email", href: "mailto:noor@example.com" }],
  },
];

const DEFAULT_VALUES: GuildValue[] = [
  {
    title: "Write it down",
    description: "Decisions live in documents, not in meetings. Anyone should be able to catch up on anything.",
  },
  {
    title: "Small and finished",
    description: "We would rather ship one complete thing than three promising ones.",
  },
  {
    title: "Customers in the room",
    description: "Every team talks to the people who use the product, every single week.",
  },
  {
    title: "Leave it calmer",
    description: "Good tools fade into the background. That includes ours — and our process.",
  },
];

/* ---------------------------------------------------------------- helpers */

/** FNV-1a string hash → 32-bit seed. */
function hashString(s: string): number {
  let h = 0x811c9dc5;
  for (let i = 0; i < s.length; i++) {
    h ^= s.charCodeAt(i);
    h = Math.imul(h, 0x01000193);
  }
  return h >>> 0;
}

/** Seeded PRNG — deterministic on server and client. */
function mulberry32(seed: number) {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

/** Soft gradient-blob portrait background, seeded from the person's name. */
function portraitBackground(name: string): string {
  const rand = mulberry32(hashString(name));
  const h = Math.floor(rand() * 360);
  const blob = (dh: number, l: number) => {
    const x = Math.round(10 + rand() * 80);
    const y = Math.round(10 + rand() * 80);
    const r = Math.round(38 + rand() * 30);
    const hue = (h + dh + 360) % 360;
    return `radial-gradient(circle at ${x}% ${y}%, hsl(${hue} 62% ${l}% / 0.95) 0%, hsl(${hue} 62% ${l}% / 0) ${r}%)`;
  };
  const angle = Math.floor(rand() * 360);
  return [
    blob(0, 72),
    blob(28 + Math.floor(rand() * 30), 60),
    blob(-30 - Math.floor(rand() * 30), 52),
    `linear-gradient(${angle}deg, hsl(${(h + 330) % 360} 35% 18%), hsl(${(h + 20) % 360} 38% 30%))`,
  ].join(", ");
}

function initialsOf(name: string): string {
  const parts = name.trim().split(/\s+/).filter(Boolean);
  const first = parts[0]?.[0] ?? "";
  const last = parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? "") : "";
  return (first + last).toUpperCase();
}

function slug(s: string): string {
  return (
    s
      .toLowerCase()
      .replace(/[^a-z0-9]+/g, "-")
      .replace(/^-|-$/g, "") || "team"
  );
}

/* ----------------------------------------------------------------- glyphs */

function ArrowGlyph({ className }: { className?: string }) {
  return (
    <svg aria-hidden viewBox="0 0 16 16" className={className} fill="none">
      <path d="M5.5 10.5l5-5m0 0H6.5m4 0v4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

function MailGlyph({ className }: { className?: string }) {
  return (
    <svg aria-hidden viewBox="0 0 16 16" className={className} fill="none">
      <rect x="2.5" y="3.75" width="11" height="8.5" rx="1.75" stroke="currentColor" strokeWidth="1.4" />
      <path d="M3 4.75l5 3.75 5-3.75" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

/* ----------------------------------------------------------------- pieces */

interface CardProps {
  person: GuildPerson;
  layoutId: string;
  panelId: string;
  open: boolean;
  /** Entry stagger index (intro) or -1 for "already on screen, no entry". */
  entryIndex: number;
  inView: boolean;
  intro: boolean;
  reducedMotion: boolean;
  onToggle: (open: boolean) => void;
}

const cardVariants: Variants = {
  hidden: { opacity: 0, y: 16, scale: 0.98 },
  shown: (delay: number) => ({
    opacity: 1,
    y: 0,
    scale: 1,
    transition: { ...SPRING, delay },
  }),
};

function PersonCard({ person, layoutId, panelId, open, entryIndex, inView, intro, reducedMotion, onToggle }: CardProps) {
  const [hovered, setHovered] = React.useState(false);
  const toggleRef = React.useRef<HTMLButtonElement>(null);
  const visible = open || hovered;
  const background = React.useMemo(() => portraitBackground(person.name), [person.name]);
  const initials = initialsOf(person.name);
  const hasPanel = Boolean(person.bio || person.links?.length);
  const delay = intro ? Math.min(Math.max(entryIndex, 0), 11) * 0.045 : 0;

  return (
    <motion.li
      layoutId={layoutId}
      layout={!reducedMotion}
      custom={delay}
      variants={cardVariants}
      initial={entryIndex < 0 || reducedMotion ? false : "hidden"}
      animate={inView ? "shown" : "hidden"}
      transition={reducedMotion ? INSTANT : SPRING}
      data-open={visible || undefined}
      className="group/card relative flex min-w-0 flex-col"
      onPointerEnter={(e) => {
        if (e.pointerType === "mouse") setHovered(true);
      }}
      onPointerLeave={(e) => {
        if (e.pointerType === "mouse") setHovered(false);
      }}
      onKeyDown={(e) => {
        if (e.key === "Escape" && visible) {
          e.stopPropagation();
          setHovered(false);
          if (open) onToggle(false);
          toggleRef.current?.focus();
        }
      }}
    >
      <div className="@container/portrait relative aspect-[4/5] w-full overflow-hidden rounded-2xl border border-white/[0.08] bg-neutral-900">
        {/* Portrait art: rests as an accent duotone, warms to full color. */}
        <div
          aria-hidden
          className={cn(
            "absolute inset-0 grayscale-[1] contrast-[1.05] transition-[filter,transform] duration-700 ease-out",
            "group-focus-within/card:grayscale-0 group-data-[open]/card:grayscale-0",
            !reducedMotion && "group-data-[open]/card:scale-[1.04]"
          )}
          style={{ backgroundImage: background }}
        >
          {person.image ? (
            // eslint-disable-next-line @next/next/no-img-element
            <img src={person.image} alt="" className="absolute inset-0 size-full object-cover" loading="lazy" />
          ) : (
            <span
              className="absolute inset-0 flex items-center justify-center font-semibold tracking-[-0.04em] text-white/85 mix-blend-soft-light select-none"
              style={{ fontSize: "28cqw" }}
            >
              {initials}
            </span>
          )}
        </div>
        <div
          aria-hidden
          className="absolute inset-0 mix-blend-color transition-opacity duration-700 ease-out group-focus-within/card:opacity-0 group-data-[open]/card:opacity-0"
          style={{ backgroundColor: "var(--guild-accent)" }}
        />
        <div
          aria-hidden
          className="absolute inset-0 bg-[radial-gradient(120%_90%_at_50%_20%,transparent_30%,rgba(10,10,10,0.55))] mix-blend-multiply transition-opacity duration-700 ease-out group-focus-within/card:opacity-40 group-data-[open]/card:opacity-40"
        />
        <div
          aria-hidden
          className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/45 via-transparent to-transparent"
        />
        {/* Hairline sheen along the top edge. */}
        <div aria-hidden className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-white/25 to-transparent" />

        <button
          ref={toggleRef}
          type="button"
          aria-expanded={hasPanel ? open : undefined}
          aria-controls={hasPanel ? panelId : undefined}
          aria-label={`${person.name}, ${person.role}${hasPanel ? (open ? " — hide bio" : " — show bio") : ""}`}
          onClick={() => {
            if (!hasPanel) return;
            const next = !visible;
            if (!next) setHovered(false);
            onToggle(next);
          }}
          className="absolute inset-0 z-10 cursor-pointer rounded-2xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--guild-accent)]"
        />

        {hasPanel ? (
          <motion.div
            id={panelId}
            inert={!visible}
            aria-hidden={!visible}
            initial={false}
            animate={
              reducedMotion
                ? { opacity: visible ? 1 : 0, y: 0 }
                : { opacity: visible ? 1 : 0, y: visible ? "0%" : "calc(100% + 12px)" }
            }
            transition={reducedMotion ? { duration: 0.15 } : { type: "spring", stiffness: 380, damping: 34 }}
            className="absolute inset-x-1.5 bottom-1.5 z-20 rounded-xl border border-white/10 bg-neutral-950/80 p-2.5 shadow-[0_8px_24px_-8px_rgba(0,0,0,0.6)] backdrop-blur-md @[13rem]/portrait:inset-x-2 @[13rem]/portrait:bottom-2 @[13rem]/portrait:p-3"
          >
            {person.bio ? (
              <p className="line-clamp-4 text-pretty text-[11px] leading-snug text-white/75 @[13rem]/portrait:text-xs">
                {person.bio}
              </p>
            ) : null}
            {person.links?.length ? (
              <ul className={cn("flex flex-wrap gap-1", person.bio && "mt-2")}>
                {person.links.map((link) => {
                  const mail = link.href.startsWith("mailto:");
                  return (
                    <li key={link.label + link.href}>
                      <a
                        href={link.href}
                        {...(!mail && /^https?:/.test(link.href) ? { target: "_blank", rel: "noreferrer" } : {})}
                        className="inline-flex items-center gap-1 rounded-full border border-white/10 bg-white/[0.04] px-2 py-0.5 text-[11px] font-medium text-white/80 transition-colors hover:border-white/25 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--guild-accent)]"
                      >
                        {mail ? <MailGlyph className="size-3" /> : null}
                        {link.label}
                        {!mail ? <ArrowGlyph className="size-3 opacity-60" /> : null}
                        {!mail && /^https?:/.test(link.href) ? <span className="sr-only"> (opens in a new tab)</span> : null}
                      </a>
                    </li>
                  );
                })}
              </ul>
            ) : null}
          </motion.div>
        ) : null}
      </div>
      <div className="mt-3 min-w-0 px-0.5">
        <p className="truncate text-sm font-medium text-white">{person.name}</p>
        <p className="mt-0.5 truncate text-xs text-white/50">{person.role}</p>
      </div>
    </motion.li>
  );
}

/* ------------------------------------------------------------------- main */

/**
 * Guild — an about & team section block in three optional parts. (1) A
 * mission statement with an oversized stat row whose numbers roll in on
 * Tally digit drums. (2) A team grid of seeded generative portraits — soft
 * gradient blobs and initials — resting as an accent duotone that warms to
 * full color on hover or focus, with a reveal panel that slides up over the
 * portrait carrying a one-line bio and links (Enter/Space or tap pins it,
 * Escape closes). A Detent department filter re-flows the grid with shared
 * layout springs. (3) A numbered values list whose rule lines draw in.
 * Reduced motion shows the final layout with crossfades only.
 */
export function Guild({
  eyebrow = "About us",
  mission = DEFAULT_MISSION,
  missionBody = DEFAULT_MISSION_BODY,
  stats = DEFAULT_STATS,
  teamTitle = "The people behind the work",
  teamDescription = "A few of the people who design, build and run the product — spread across eleven countries and one shared calendar.",
  people = DEFAULT_PEOPLE,
  showFilter = true,
  allLabel = "Everyone",
  filter,
  defaultFilter = null,
  onFilterChange,
  onPersonToggle,
  valuesTitle = "What we hold to",
  values = DEFAULT_VALUES,
  accentColor = DEFAULT_ACCENT,
  ref,
  className,
  style,
  ...props
}: GuildProps) {
  const reducedMotion = useReducedMotion();
  const [replayKey, setReplayKey] = React.useState(0);
  const [innerFilter, setInnerFilter] = React.useState<string | null>(defaultFilter);
  const activeFilter = filter !== undefined ? filter : innerFilter;
  const [pinned, setPinned] = React.useState<string | null>(null);
  const [announcement, setAnnouncement] = React.useState("");

  const uid = React.useId().replace(/[:]/g, "");
  const headingId = `${uid}-heading`;

  const teams = React.useMemo(() => {
    const seen: string[] = [];
    for (const p of people) if (!seen.includes(p.team)) seen.push(p.team);
    return seen;
  }, [people]);

  const setFilter = React.useCallback(
    (team: string | null) => {
      if (filter === undefined) setInnerFilter(team);
      onFilterChange?.(team);
      setPinned(null);
      const count = team ? people.filter((p) => p.team === team).length : people.length;
      setAnnouncement(`Showing ${count} ${count === 1 ? "person" : "people"}${team ? ` in ${team}` : ""}.`);
    },
    [filter, onFilterChange, people]
  );

  React.useImperativeHandle(
    ref,
    () => ({
      replay: () => {
        setPinned(null);
        setReplayKey((k) => k + 1);
      },
      setFilter,
    }),
    [setFilter]
  );

  const showMission = mission !== null && mission !== undefined && mission !== false;
  const showStats = stats.length > 0;
  const showTeam = people.length > 0;
  const showValues = values.length > 0;
  const filterOn = showFilter && teams.length > 1;

  return (
    <section
      data-crucible="guild"
      aria-labelledby={showMission ? headingId : undefined}
      aria-label={showMission ? undefined : "About"}
      className={cn("@container relative isolate w-full overflow-hidden bg-neutral-950 text-white", className)}
      style={{ ["--guild-accent" as string]: accentColor, ...style }}
      {...props}
    >
      {/* Faint accent bloom + top hairline. */}
      <div aria-hidden className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[28rem]">
        <div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-white/12 to-transparent" />
        <div
          className="absolute inset-0"
          style={{
            background: `radial-gradient(60% 70% at 20% 0%, color-mix(in srgb, ${accentColor} 10%, transparent), transparent 70%)`,
          }}
        />
      </div>

      <GuildBody
        key={replayKey}
        uid={uid}
        headingId={headingId}
        reducedMotion={reducedMotion}
        eyebrow={eyebrow}
        mission={showMission ? mission : null}
        missionBody={missionBody}
        stats={showStats ? stats : []}
        teamTitle={teamTitle}
        teamDescription={teamDescription}
        people={showTeam ? people : []}
        teams={teams}
        filterOn={filterOn}
        allLabel={allLabel}
        activeFilter={activeFilter}
        setFilter={setFilter}
        pinned={pinned}
        setPinned={(name, open) => {
          setPinned(open ? name : null);
          const person = people.find((p) => p.name === name);
          if (person) onPersonToggle?.(person, open);
        }}
        valuesTitle={valuesTitle}
        values={showValues ? values : []}
      />

      <p role="status" aria-live="polite" className="sr-only">
        {announcement}
      </p>
    </section>
  );
}

interface BodyProps {
  uid: string;
  headingId: string;
  reducedMotion: boolean;
  eyebrow: React.ReactNode;
  mission: React.ReactNode;
  missionBody: React.ReactNode;
  stats: GuildStat[];
  teamTitle: React.ReactNode;
  teamDescription: React.ReactNode;
  people: GuildPerson[];
  teams: string[];
  filterOn: boolean;
  allLabel: string;
  activeFilter: string | null;
  setFilter: (team: string | null) => void;
  pinned: string | null;
  setPinned: (name: string, open: boolean) => void;
  valuesTitle: React.ReactNode;
  values: GuildValue[];
}

function GuildBody({
  uid,
  headingId,
  reducedMotion,
  eyebrow,
  mission,
  missionBody,
  stats,
  teamTitle,
  teamDescription,
  people,
  teams,
  filterOn,
  allLabel,
  activeFilter,
  setFilter,
  pinned,
  setPinned,
  valuesTitle,
  values,
}: BodyProps) {
  const missionRef = React.useRef<HTMLDivElement>(null);
  const teamRef = React.useRef<HTMLDivElement>(null);
  const valuesRef = React.useRef<HTMLOListElement>(null);
  const missionInView = useInView(missionRef, { once: true, amount: 0.3 });
  const teamInView = useInView(teamRef, { once: true, amount: 0.15 });
  const valuesInView = useInView(valuesRef, { once: true, amount: 0.3 });
  const mShow = reducedMotion || missionInView;
  const tShow = reducedMotion || teamInView;
  const vShow = reducedMotion || valuesInView;

  // Intro window for the grid's staggered plate-in; filter changes after it
  // only fade newly-entering cards in place.
  const [gridIntro, setGridIntro] = React.useState(true);
  React.useEffect(() => {
    if (!teamInView) return;
    const t = window.setTimeout(() => setGridIntro(false), 900);
    return () => window.clearTimeout(t);
  }, [teamInView]);

  const visiblePeople = React.useMemo(
    () =>
      people
        .map((p, i) => ({ p, i }))
        .filter(({ p }) => !filterOn || activeFilter === null || p.team === activeFilter),
    [people, filterOn, activeFilter]
  );

  // Cards already on screen keep their identity (shared layout); only new ones fade in.
  // Tracked as state (previous-render pattern) so render stays pure.
  const [filterHistory, setFilterHistory] = React.useState<{ current: string | null; previous?: string | null }>({
    current: activeFilter,
  });
  if (filterHistory.current !== activeFilter) {
    setFilterHistory({ current: activeFilter, previous: filterHistory.current });
  }
  const previously = React.useMemo(() => {
    if (filterHistory.previous === undefined) return null;
    const prev = filterHistory.previous;
    const set = new Set<number>();
    people.forEach((p, i) => {
      if (!filterOn || prev === null || p.team === prev) set.add(i);
    });
    return set;
  }, [filterHistory.previous, people, filterOn]);

  const rise = (delay: number) =>
    reducedMotion
      ? // Keep the same `initial` as the motion branch and animate straight to
        // the resting pose with a plain fade: SSR commits the hidden pose before
        // the reduced-motion query resolves, and swapping `initial` to `false`
        // after mount leaves it stuck there.
        {
          initial: { opacity: 0, y: 14, filter: "blur(4px)" },
          animate: { opacity: 1, y: 0, filter: "blur(0px)" },
          transition: { duration: 0.2, ease: "linear" as const },
        }
      : {
          initial: { opacity: 0, y: 14, filter: "blur(4px)" },
          animate: mShow ? { opacity: 1, y: 0, filter: "blur(0px)" } : undefined,
          transition: { duration: 0.6, ease: EASE, delay },
        };

  const grid = (
    <ul
      aria-label={activeFilter ? `${activeFilter} team` : "Team"}
      className="grid grid-cols-2 gap-x-3 gap-y-6 @lg:gap-x-4 @xl:grid-cols-3 @3xl:grid-cols-4 @4xl:gap-x-5"
    >
      {visiblePeople.map(({ p, i }, order) => (
        <PersonCard
          key={i}
          person={p}
          layoutId={`${uid}-person-${i}`}
          panelId={`${uid}-bio-${i}`}
          open={pinned === p.name}
          entryIndex={previously && previously.has(i) ? -1 : order}
          inView={tShow}
          intro={gridIntro && !reducedMotion}
          reducedMotion={reducedMotion}
          onToggle={(open) => setPinned(p.name, open)}
        />
      ))}
    </ul>
  );

  const filterValue = activeFilter === null ? ALL : slug(activeFilter);
  const tabLabel = (label: string, count: number) => (
    <span className="inline-flex items-center gap-1.5">
      {label}
      <span className="text-[11px] tabular-nums text-white/35">{count}</span>
    </span>
  );

  return (
    <div className="mx-auto flex w-full max-w-6xl flex-col gap-16 px-5 py-14 @xl:px-8 @3xl:gap-24 @3xl:py-20">
      {mission !== null || stats.length > 0 ? (
        <div ref={missionRef} className="flex flex-col gap-10 @3xl:gap-14">
          {mission !== null ? (
            <div className="grid gap-5 @4xl:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)] @4xl:items-end @4xl:gap-16">
              <div className="flex flex-col gap-5">
                {eyebrow !== null && eyebrow !== undefined ? (
                  <motion.p
                    {...rise(0)}
                    className="inline-flex items-center gap-2 self-start text-xs font-medium tracking-[0.14em] text-white/55 uppercase"
                  >
                    <span aria-hidden className="size-1.5 rounded-full" style={{ backgroundColor: "var(--guild-accent)" }} />
                    {eyebrow}
                  </motion.p>
                ) : null}
                <motion.h2
                  id={headingId}
                  {...rise(0.06)}
                  className="text-balance text-[1.75rem] leading-[1.12] font-semibold tracking-[-0.025em] text-white @xl:text-4xl @4xl:text-5xl"
                >
                  {mission}
                </motion.h2>
              </div>
              {missionBody !== null && missionBody !== undefined ? (
                <motion.p {...rise(0.14)} className="max-w-md text-pretty text-sm leading-relaxed text-white/55 @xl:text-base">
                  {missionBody}
                </motion.p>
              ) : null}
            </div>
          ) : null}

          {stats.length > 0 ? (
            <dl
              className="grid border-t border-white/[0.08]"
              style={{ gridTemplateColumns: "repeat(auto-fit, minmax(min(8rem, 100%), 1fr))" }}
            >
              {stats.map((stat, i) => (
                <motion.div
                  key={i}
                  {...rise(0.18 + i * 0.06)}
                  className="relative flex flex-col-reverse gap-1.5 border-b border-white/[0.08] py-5 pr-4 @xl:py-7"
                >
                  <dt className="text-xs text-white/50 @xl:text-sm">{stat.label}</dt>
                  <dd className="flex items-baseline text-4xl font-semibold tracking-[-0.03em] text-white tabular-nums @3xl:text-5xl @5xl:text-6xl">
                    {stat.prefix ? <span className="mr-0.5 text-white/60">{stat.prefix}</span> : null}
                    <Tally
                      value={stat.value}
                      start={stat.start ?? 0}
                      decimals={stat.decimals ?? 0}
                      formatOptions={stat.formatOptions}
                      duration={1.2}
                      delay={0.2 + i * 0.08}
                      glow={false}
                    />
                    {stat.suffix ? (
                      <span className="ml-0.5" style={{ color: "var(--guild-accent)" }}>
                        {stat.suffix}
                      </span>
                    ) : null}
                  </dd>
                </motion.div>
              ))}
            </dl>
          ) : null}
        </div>
      ) : null}

      {people.length > 0 ? (
        <div ref={teamRef} className="flex flex-col gap-7">
          <div className="flex max-w-2xl flex-col gap-2">
            <h3 className="text-xl font-semibold tracking-[-0.015em] text-white @xl:text-2xl">{teamTitle}</h3>
            {teamDescription !== null && teamDescription !== undefined ? (
              <p className="text-pretty text-sm leading-relaxed text-white/55">{teamDescription}</p>
            ) : null}
          </div>
          {filterOn ? (
            <Detent
              value={filterValue}
              onValueChange={(v) => setFilter(v === ALL ? null : (teams.find((t) => slug(t) === v) ?? null))}
              label="Filter by team"
              className="gap-6"
              listClassName="max-w-full overflow-x-auto [scrollbar-width:none]"
              items={[
                { value: ALL, label: tabLabel(allLabel, people.length), content: activeFilter === null ? grid : null },
                ...teams.map((t) => ({
                  value: slug(t),
                  label: tabLabel(t, people.filter((p) => p.team === t).length),
                  content: activeFilter === t ? grid : null,
                })),
              ]}
            />
          ) : (
            grid
          )}
        </div>
      ) : null}

      {values.length > 0 ? (
        <div className="grid gap-8 @4xl:grid-cols-[minmax(0,1fr)_minmax(0,2fr)] @4xl:gap-16">
          <h3 className="text-xl font-semibold tracking-[-0.015em] text-white @xl:text-2xl">{valuesTitle}</h3>
          <ol ref={valuesRef} className="grid gap-x-10 gap-y-8 @2xl:grid-cols-2">
            {values.map((v, i) => (
              <li key={i} className="relative pt-5">
                <motion.span
                  aria-hidden
                  className="absolute inset-x-0 top-0 h-px origin-left"
                  style={{
                    background: `linear-gradient(90deg, var(--guild-accent), color-mix(in srgb, var(--guild-accent) 25%, rgba(255,255,255,0.1)) 40%, rgba(255,255,255,0.08))`,
                  }}
                  initial={{ scaleX: 0 }}
                  animate={vShow ? { scaleX: 1 } : undefined}
                  transition={reducedMotion ? { duration: 0.2, ease: "linear" } : { duration: 0.75, ease: EASE, delay: i * 0.09 }}
                />
                <motion.div
                  className="flex gap-4"
                  initial={{ opacity: 0, y: 8 }}
                  animate={vShow ? { opacity: 1, y: 0 } : undefined}
                  transition={reducedMotion ? { duration: 0.2, ease: "linear" } : { duration: 0.5, ease: EASE, delay: 0.12 + i * 0.09 }}
                >
                  <span
                    aria-hidden
                    className="pt-0.5 font-mono text-xs tabular-nums"
                    style={{ color: "var(--guild-accent)" }}
                  >
                    {String(i + 1).padStart(2, "0")}
                  </span>
                  <div className="min-w-0">
                    <p className="text-base font-medium text-white">{v.title}</p>
                    {v.description ? (
                      <p className="mt-1.5 text-pretty text-sm leading-relaxed text-white/55">{v.description}</p>
                    ) : null}
                  </div>
                </motion.div>
              </li>
            ))}
          </ol>
        </div>
      ) : null}
    </div>
  );
}
```
