Guild
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.
"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 "@/registry/default/navigation/detent/detent";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/use-reduced-motion";
import { Tally } from "@/registry/default/text/tally/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>
);
}
Installation
CLI
npx shadcn@latest add @crucible/guildManual — install dependencies, then copy the source
npm install motionProps
| 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 all props of Omit<React.ComponentPropsWithoutRef<"section">, "children" | "title"> — they pass through to the underlying element. | |||
Honors prefers-reduced-motion with a designed static fallback, and passes className through.