FamilyTree
Render and update a subject-centered family tree from app-owned graph data.
FamilyTree renders a readable family neighborhood around one subject. Your app owns the people, relationship records, editing UI, persistence, permissions, and custom card markup. Tree owns measured layout, partner grouping, child routing, SVG edges, viewport behavior, accessibility props, and the small stylesheet.
For production apps, use graph mode. It gives every person, partnership, parent-child link, and guardianship link a stable app-owned identity, so changing your JSON and re-rendering the component is the whole data flow.
The Mental Model
A family tree graph has five concepts:
people: records keyed by person ID.subject: the person whose neighborhood should be centered.partnershipGroups: visible partner, spouse, co-parent, or unknown-parent groups.parentChildLinks: one parent-to-child lineage record per parent.guardianshipLinks: optional non-parent caregiver links.
import { FamilyTree, type FamilyGraph } from "@memoir/tree";
import "@memoir/tree/styles.css";
type Person = {
id: string;
name: string;
};
const graph: FamilyGraph<Person> = {
people: {
alex: { id: "alex", name: "Alex" },
jordan: { id: "jordan", name: "Jordan" },
riley: { id: "riley", name: "Riley" },
},
subject: "riley",
partnershipGroups: [
{ id: "alex-jordan", partners: ["alex", "jordan"], relation: "spouse" },
],
parentChildLinks: [
{ id: "alex-riley", groupId: "alex-jordan", parentId: "alex", childId: "riley" },
{ id: "jordan-riley", groupId: "alex-jordan", parentId: "jordan", childId: "riley" },
],
};
export function FamilyPanel() {
return <FamilyTree graph={graph} />;
}That is all the renderer needs. The tree derives layout and edges from those facts on every render.
Add Someone And Re-Render
Because the graph is plain React state, adding a family member is just an immutable state update. The tree re-renders from the new graph.
This example adds a child to the existing alex-jordan partnership group:
import { useState } from "react";
import { FamilyTree, type FamilyGraph } from "@memoir/tree";
type Person = {
id: string;
name: string;
};
export function EditableFamilyTree({ initialGraph }: { initialGraph: FamilyGraph<Person> }) {
const [graph, setGraph] = useState(initialGraph);
function addChild() {
const childId = crypto.randomUUID();
const groupId = "alex-jordan";
setGraph((current) => ({
...current,
people: {
...current.people,
[childId]: { id: childId, name: "New child" },
},
parentChildLinks: [
...current.parentChildLinks,
{
id: `alex-${childId}`,
groupId,
parentId: "alex",
childId,
relation: "biological",
},
{
id: `jordan-${childId}`,
groupId,
parentId: "jordan",
childId,
relation: "biological",
},
],
}));
}
return (
<>
<button type="button" onClick={addChild}>
Add child
</button>
<FamilyTree graph={graph} />
</>
);
}Nothing else is required. Keep IDs stable after creation, save the same graph shape to your database, and render the tree from whatever graph comes back from your app state.
Add A New Union
If the child belongs to a new spouse, partner, or co-parent group, add the partner person, add a partnership group, then attach the child links to that group.
const partnerId = "morgan";
const childId = "casey";
const groupId = "alex-morgan";
setGraph((current) => ({
...current,
people: {
...current.people,
[partnerId]: { id: partnerId, name: "Morgan" },
[childId]: { id: childId, name: "Casey" },
},
partnershipGroups: [
...current.partnershipGroups,
{
id: groupId,
partners: ["alex", partnerId],
relation: "coparent",
status: "former",
order: 2,
},
],
parentChildLinks: [
...current.parentChildLinks,
{ id: `alex-${childId}`, groupId, parentId: "alex", childId },
{ id: `${partnerId}-${childId}`, groupId, parentId: partnerId, childId },
],
}));The child edge now comes out of the alex-morgan union, not from Alex alone and not from the wrong partner group. Parentage does not create a spouse or partner relationship by itself; use partnershipGroups only when the relationship should render as a visible spouse, partner, co-parent, or unknown-parent group.
Parent-Child Links
parentChildLinks are per parent. A two-parent biological child normally has two links with the same groupId, relation, status, and order.
parentChildLinks: [
{ id: "alex-riley", groupId: "alex-jordan", parentId: "alex", childId: "riley" },
{ id: "jordan-riley", groupId: "alex-jordan", parentId: "jordan", childId: "riley" },
];Tree groups those links into one rendered parentage relationship and routes it from the visible parent pair. The rendered edge exposes the shared groupId as its source ID, which keeps editor diffs tied to the union in your app state. A two-parent parentage group does not draw a spouse-style bar unless there is an explicit visible partnership group for those parents.
Equivalent link reordering does not change rendered relationship IDs. Stable id, groupId, and order values are what make JSON diffs and re-renders predictable.
Each rendered tree shows a person at most once. If the same person is reachable through several paths, Tree assigns one primary visible role before layout: direct roles such as parent, guardian, child, partner, or co-parent outrank sibling, cousin, and other lateral roles.
Per-Parent Lineage
Lineage belongs to each parent-child link, not to the whole union. If Alex is biological and Jordan is step, model that truth directly:
parentChildLinks: [
{
id: "alex-riley",
groupId: "alex-jordan",
parentId: "alex",
childId: "riley",
relation: "biological",
},
{
id: "jordan-riley",
groupId: "alex-jordan",
parentId: "jordan",
childId: "riley",
relation: "step",
},
];Because the relation kinds differ, Tree keeps those as distinct rendered relationship edges. It does not merge biological, step, adoptive, foster, and unknown lineage into one misleading edge.
Multiple Unions
One person can belong to multiple partnership groups. Children stay anchored to the group that produced or raised them:
partnershipGroups: [
{ id: "alex-jordan", partners: ["alex", "jordan"], relation: "spouse", order: 1 },
{ id: "alex-morgan", partners: ["alex", "morgan"], relation: "coparent", status: "former", order: 2 },
],
parentChildLinks: [
{ id: "alex-riley", groupId: "alex-jordan", parentId: "alex", childId: "riley" },
{ id: "jordan-riley", groupId: "alex-jordan", parentId: "jordan", childId: "riley" },
{ id: "alex-casey", groupId: "alex-morgan", parentId: "alex", childId: "casey" },
{ id: "morgan-casey", groupId: "alex-morgan", parentId: "morgan", childId: "casey" },
];Use order when several partner groups need a predictable left-to-right order.
Guardianship
Guardianship is separate from parentage. Use it for caregivers who should not be modeled as parents:
guardianshipLinks: [
{
id: "morgan-riley-guardian",
guardianId: "morgan",
childId: "riley",
relation: "guardian",
},
];If the same person is both a parent and a guardian, model both facts. Tree keeps those relationship kinds distinct.
Unknown Parents
Unknown partner placeholders are display/layout facts, not automatic spouse bars. A partnership with relation: "unknown" or status: "unknown" renders the placeholder card without drawing a horizontal partnership edge.
If the unknown placeholder is also an actual co-parent, include it in parentChildLinks:
people: {
self: { id: "self", name: "Self" },
unknown: { id: "unknown", name: "Unknown parent" },
child: { id: "child", name: "Child" },
},
partnershipGroups: [
{ id: "self-unknown", partners: ["self", "unknown"], relation: "unknown" },
],
parentChildLinks: [
{ id: "self-child", groupId: "self-unknown", parentId: "self", childId: "child" },
{ id: "unknown-child", groupId: "self-unknown", parentId: "unknown", childId: "child", relation: "unknown" },
];Custom Cards
Most apps should render their own cards. Spread the provided root props so Tree can attach ARIA attributes, keyboard handlers, click handlers, and stable data attributes.
import type { FamilyCardProps } from "@memoir/tree";
function ProfileCard({ focused, person, relation, ...rootProps }: FamilyCardProps<Person>) {
return (
<article {...rootProps}>
<strong>{person.name}</strong>
<small>{relation.label}</small>
</article>
);
}
<FamilyTree graph={graph} card={ProfileCard} />;Use cardProps for typed app-owned card inputs:
<FamilyTree
graph={graph}
card={ProfileCard}
cardProps={(person) => ({
href: `/people/${person.id}`,
canEdit: currentUserCanEdit,
})}
/>Graph-mode cards also receive placement metadata:
placement?: {
partnershipGroupIds: string[];
parentChildLinkIds: string[];
guardianshipLinkIds: string[];
visibleRelationshipIds: string[];
};Use that metadata when your card needs to open the exact relationship editor that placed the person.
Selection And Editing Hooks
Tree does not mutate your graph. Use handlers to open your app UI:
<FamilyTree
graph={graph}
selected={selectedPersonId}
onPersonClick={(_person, personId) => setSelectedPersonId(personId)}
onAddRelationship={(_person, personId) => openAddRelativeDialog(personId)}
readOnly={!canEditFamily}
/>readOnly only prevents add-relationship actions from being passed into cards. Selection still belongs to your app.
Layout Behavior
Family layout is subject-centered and neighborhood-based:
- Ancestor generations.
- Subject row with siblings, half-siblings, and partners.
- Descendant generations.
Partnership groups and child groups participate in layout before SVG edges are routed. Spouse bars, divorce markers, parent-child buses, and guardianship lines are derived from measured card positions.
Two-parent child groups use the visible midpoint between parent cards. Multi-child groups split through a horizontal bus in the clear gap below the parent row and above the child row.
The layout prevents same-row card overlap and keeps child groups near their visible parent anchors. Large blended families can become wide; use limits to control the visible neighborhood.
<FamilyTree
graph={graph}
limits={{ ancestorGenerations: 3, descendantGenerations: 2, partners: null }}
/>Default limits are 2 ancestor generations, 2 descendant generations, no lateral family expansion, 4 grandparents, 4 parents, 8 siblings, 8 half-siblings, 3 partners, 8 children, and 8 grandchildren. Set a cap to null to disable that cap.
Default spacing is compact:
{ row: 80, column: 24, padding: 24 }Override spacing only when your cards need a different density:
<FamilyTree graph={graph} spacing={{ row: 72, column: 20, padding: 24 }} />Viewport
The subject is centered by default after cards are measured. Use defaultViewport for a custom uncontrolled starting position, or initialViewport for explicit modes:
<FamilyTree graph={graph} initialViewport={{ mode: "center-person", personId: "riley" }} />treeApiRef exposes a small API:
centerPerson(personId)fitToSubject()resetViewport()
The default interactionMode is "pan". Use "pan-page-scroll" when vertical touch gestures should scroll the page, "scroll" for normal browser scrollbars, or "none" for a static viewport. Native controls inside cards keep their own pointer behavior; add data-tree-drag-ignore to custom card elements that should not start panning.
Simple Mode
Simple mode is still useful for examples and small static trees:
import { FamilyTree, rel } from "@memoir/tree";
const people = {
alex: { id: "alex", name: "Alex" },
morgan: { id: "morgan", name: "Morgan" },
casey: { id: "casey", name: "Casey" },
jordan: { id: "jordan", name: "Jordan" },
riley: { id: "riley", name: "Riley" },
};
const relationships = [
rel.parents("alex", ["morgan", "casey"]),
rel.partner("alex", "jordan", { relation: "spouse" }),
rel.children(["alex", "jordan"], ["riley"]),
];
export function FamilyPanel() {
return <FamilyTree people={people} subject="alex" relationships={relationships} />;
}Prefer graph mode when your app needs stable relationship IDs, multiple unions, per-parent lineage, guardianship, unknown parents, or clean JSON diffs.