A studio site with no pages, for an architecture practice

RGL's projects happen in stages. A gallery of covers flattens them into one grid, so the whole site is one tree that opens in place, and anything can stay open next to anything else.

WhenAug '26
ForRGL Practice
DisciplineDesign engineering, front-end, studio site
ToolsNext.js, React, Motion, Sanity, Tailwind

Problem

RGL is an architecture and research practice in Virginia and New York. Their work is drawings, models and site photography, and every project moves through the same three stages: exploration, design, development.

Most studio sites are a grid of covers and a page per project. That shape flattens a project into one gallery, and for RGL the stages are the point. The first value in their brand notes is research before form, so the site had to show the research, not just the result.

So I took pages away. The site is one tree. Projects branch into stages, stages open into their media, and nothing ever leaves the page.

Five prototypes

I built five versions in eleven days, from July 22 to August 2, each a different answer to one question: where does a stage's media go?

Prototype 1: a feed. Every drawing cropped to a square thumbnail, every stage the same size.
Prototype 3: the tree is the site. Stages open into a four-up grid, uncropped, but only one project at a time.

Paths not taken

The fifth is what shipped: a band of images at one height, each at its own ratio, running to the edge of the screen. I also put all five on one demo deploy for the team, switchable with the 1 to 5 keys, each on their real content.

I deliberately left out project pages, routes, navigation and external links. The type is two sizes, 10px and 12px, in two faces, and the palette is white and one red on a light grey canvas.

Opening is one motion

The common gesture is opening a second branch while the first is still moving. So panel height runs on a spring, which picks up from wherever the panel is instead of restarting. No bounce, because overshooting a height clips the content.

Show code
"use client";

import {
  animate,
  AnimatePresence,
  motion,
  PresenceContext,
  usePresence,
  useReducedMotion,
  type Transition,
} from "framer-motion";
import {
  useCallback,
  useContext,
  useEffect,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
  type CSSProperties,
  type ReactNode,
} from "react";
import { cn } from "@/lib/utils";

export type BranchNode = {
  /** Stable id. Open state is keyed on it, so derive it from a slug, never a label. */
  id: string;
  label: string;
  /** Plain lines, rendered as a rail-and-tick list one level deeper. */
  lines?: string[];
  children?: BranchNode[];
};

export type BranchContentContext = {
  depth: number;
  /** The node's siblings, in order, and the tree's open ids, so content can
   *  respond to what else is open (e.g. sibling-scale). */
  siblings: readonly BranchNode[];
  openIds: ReadonlySet<string>;
};

interface BranchTreeProps {
  nodes: BranchNode[];
  /** Open on first render. Children of an open node are not opened for you. */
  defaultOpenIds?: string[];
  /** Custom content for a node, rendered above its children inside the panel. */
  renderContent?: (node: BranchNode, context: BranchContentContext) => ReactNode;
  /** Shows the Expand / Collapse control in the top-right corner. */
  expandAll?: boolean;
  className?: string;
}

/**
 * A spring rather than a tween because opening a second branch mid-animation is
 * the common gesture here: a spring retargets from the current height with its
 * velocity intact, where a tween restarts a fresh curve and reads as a stutter.
 * bounce 0 because overshooting a height would clip the content.
 */
const panelSpring = { type: "spring", duration: 0.4, bounce: 0 } as const;

function collectIds(nodes: BranchNode[]): string[] {
  return nodes.flatMap((node) => [node.id, ...collectIds(node.children ?? [])]);
}

/** Every descendant id under each node, so collapsing a parent can clear them. */
function buildDescendantsMap(nodes: BranchNode[]): Map<string, string[]> {
  const map = new Map<string, string[]>();
  function walk(node: BranchNode): string[] {
    const descendants = (node.children ?? []).flatMap((child) => [child.id, ...walk(child)]);
    map.set(node.id, descendants);
    return descendants;
  }
  for (const node of nodes) walk(node);
  return map;
}

/** One <li>: a continuous 1px rail (a single box, so it cannot seam) and a 16px tick. */
function Item({ children }: { children: ReactNode }) {
  return (
    <li
      className={cn(
        "relative",
        "before:absolute before:top-0 before:bottom-0 before:z-[1] before:w-px before:bg-foreground",
        "last:before:bottom-auto last:before:h-[9px]",
        // Rail and tick share one expression, so they cannot drift apart.
        "before:left-[calc(var(--rail-inset)-var(--rail))]",
      )}
    >
      {children}
    </li>
  );
}

function Row({ children }: { children: ReactNode }) {
  return (
    <div
      className={cn(
        "relative z-[1] flex min-h-[18px] items-center",
        "before:absolute before:top-[9px] before:h-px before:w-4 before:bg-foreground",
        "before:left-[calc(var(--rail-inset)-var(--rail))]",
      )}
    >
      {children}
    </div>
  );
}

function List({ root, children }: { root?: boolean; children: ReactNode }) {
  return (
    <ul
      data-root={root ? "" : undefined}
      // minmax(0, 1fr): a grid track otherwise grows to its widest child, so a
      // wide media band would stretch the tree instead of scrolling inside it.
      className="relative m-0 grid list-none grid-cols-[minmax(0,1fr)] gap-0 p-0 pl-[var(--rail)]"
    >
      {children}
    </ul>
  );
}

/**
 * Text is never a block floating free of the rails: each line gets its own
 * tick. Lines are usually strings; any inline node (a loading bar) works too.
 */
export function BranchLines({ lines }: { lines: ReactNode[] }) {
  return (
    <List>
      {lines.map((line, index) => (
        <Item key={typeof line === "string" ? `${index}-${line}` : index}>
          <Row>
            <span className="min-w-0 leading-[18px] [overflow-wrap:anywhere]">{line}</span>
          </Row>
        </Item>
      ))}
    </List>
  );
}

type BranchProps = {
  nodes: BranchNode[];
  depth: number;
  openIds: Set<string>;
  onToggle: (id: string) => void;
  onExited: (id: string) => void;
  instant: boolean;
  renderContent?: BranchTreeProps["renderContent"];
};

function Branch(props: BranchProps) {
  const { nodes, depth } = props;
  return (
    <List root={depth === 0}>
      {nodes.map((node) => (
        <Node
          key={node.id}
          {...props}
          node={node}
          siblings={nodes}
        />
      ))}
    </List>
  );
}

/**
 * The animated panel. Height is measured with offsetHeight, which is layout
 * size, and driven through framer's animate(). Animating to `height: "auto"`
 * makes framer measure with getBoundingClientRect, which includes ancestor
 * transforms: under any scale() the spring ran to the scaled height and then
 * snapped to the real one when it switched back to auto, on every open and
 * close. Calling animate() on the element again mid-flight retargets from the
 * current height with its velocity, so a reversed panel never restarts.
 */
function Panel({
  id,
  transition,
  instant,
  children,
}: {
  id: string;
  transition: Transition;
  instant: boolean;
  children: ReactNode;
}) {
  const [isPresent, safeToRemove] = usePresence();
  // AnimatePresence initial={false}: panels open on first render don't animate in.
  const presence = useContext(PresenceContext);
  const [appear] = useState(() => presence?.initial !== false && !instant);
  const ref = useRef<HTMLDivElement>(null);
  const bodyRef = useRef<HTMLDivElement>(null);
  const run = useRef(0);

  useLayoutEffect(() => {
    const el = ref.current;
    const body = bodyRef.current;
    if (!el || !body) return;
    const token = ++run.current;
    const settled = el.style.height === "" || el.style.height === "auto";

    if (isPresent) {
      if (settled) return; // first paint at auto, nothing to animate
      animate(el, { height: body.offsetHeight }, transition).then(() => {
        // Back to auto so nested panels opening later can grow it.
        if (run.current === token) el.style.height = "auto";
      });
      return;
    }

    // Closing: from a settled auto height start at the real layout height;
    // mid-flight, retarget the running spring.
    const target = settled ? [el.offsetHeight, 0] : 0;
    animate(el, { height: target }, transition).then(() => {
      if (run.current === token) safeToRemove?.();
    });
    // eslint-disable-next-line react-hooks/exhaustive-deps -- runs per presence change only
  }, [isPresent]);

  useEffect(() => () => void run.current++, []);

  return (
    <div
      ref={ref}
      id={id}
      // Clip the block axis only, so the height animates while wide content
      // (a media band) can still escape sideways. Scroll anchoring off: it
      // keeps correcting the offset while one panel closes and another opens.
      className="-mx-1 overflow-x-visible overflow-y-clip px-1 [overflow-anchor:none]"
      style={appear ? { height: 0 } : undefined}
    >
      <div ref={bodyRef}>{children}</div>
    </div>
  );
}

function Node({
  node,
  depth,
  openIds,
  onToggle,
  onExited,
  instant,
  renderContent,
  siblings,
}: BranchProps & { node: BranchNode; siblings: BranchNode[] }) {
  const reduceMotion = useReducedMotion();
  const content = renderContent?.(node, { depth, siblings, openIds }) ?? null;
  const hasLines = Boolean(node.lines?.length);
  const isOpen = openIds.has(node.id);
  const panelId = `${node.id}-panel`;
  const transition = reduceMotion || instant ? { duration: 0 } : panelSpring;
  const skipMotion = Boolean(reduceMotion || instant);

  if (!content && !hasLines && !node.children?.length) {
    return (
      <Item>
        <Row>
          <span className="min-w-0 leading-[18px] [overflow-wrap:anywhere]">{node.label}</span>
        </Row>
      </Item>
    );
  }

  return (
    <Item>
      <Row>
        <button
          type="button"
          aria-expanded={isOpen}
          aria-controls={panelId}
          onClick={() => onToggle(node.id)}
          className={cn(
            "inline-flex cursor-pointer items-center gap-1 rounded-[2px] border-0 bg-transparent p-0",
            "font-medium leading-[18px] text-foreground [-webkit-tap-highlight-color:transparent]",
            "transition-colors hover:text-muted-foreground",
            "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
          )}
        >
          <span>{node.label}</span>
          <motion.span
            aria-hidden="true"
            className="inline-flex size-1.5 flex-none items-center justify-center text-foreground"
            initial={false}
            animate={{ rotate: isOpen ? 90 : 0 }}
            transition={transition}
          >
            <svg width="6" height="6" viewBox="0 0 8 8" fill="none" className="block">
              <path
                d="M2.5 1 L6 4 L2.5 7"
                stroke="currentColor"
                strokeWidth="1"
                strokeLinecap="round"
                strokeLinejoin="round"
              />
            </svg>
          </motion.span>
        </button>
      </Row>

      <AnimatePresence initial={false} onExitComplete={() => onExited(node.id)}>
        {isOpen ? (
          <Panel id={panelId} transition={transition} instant={skipMotion}>
            {/* Flex column so child margins cannot collapse, and every open-state
                gap is padding on this inner box: the measured height is the
                border box, and the panel itself can still reach zero. */}
            <div
              className={cn(
                "flex flex-col",
                depth === 0 && "pb-[var(--gap)]",
                depth === 0 && !content && "pt-[var(--gap)]",
              )}
            >
              {content ? <div className="min-w-0 py-[var(--gap)]">{content}</div> : null}
              {hasLines ? <BranchLines lines={node.lines!} /> : null}
              {node.children?.length ? (
                <Branch
                  nodes={node.children}
                  depth={depth + 1}
                  openIds={openIds}
                  onToggle={onToggle}
                  onExited={onExited}
                  instant={instant}
                  renderContent={renderContent}
                />
              ) : null}
            </div>
          </Panel>
        ) : null}
      </AnimatePresence>
    </Item>
  );
}

/**
 * A file-branch tree that expands in place. One component owns open state (a
 * single Set of ids), so Expand / Collapse, descendant pruning and sibling
 * scaling all agree. It is not an accordion: any number of branches stay open.
 */
export function BranchTree({
  nodes,
  defaultOpenIds = [],
  renderContent,
  expandAll = true,
  className,
}: BranchTreeProps) {
  const [openIds, setOpenIds] = useState<Set<string>>(() => new Set(defaultOpenIds));
  // Bulk Expand / Collapse changes every nested level at once; nested
  // height:auto animations would fight and snap, so that one change is instant.
  const [instant, setInstant] = useState(false);
  const allIds = useMemo(() => collectIds(nodes), [nodes]);
  const descendantsMap = useMemo(() => buildDescendantsMap(nodes), [nodes]);
  const allExpanded = allIds.length > 0 && allIds.every((id) => openIds.has(id));

  const onToggle = useCallback((id: string) => {
    setOpenIds((prev) => {
      const next = new Set(prev);
      // Closing removes only the node itself. Its descendants stay open until
      // the panel has finished exiting (onExited), so nested panels never snap.
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });
  }, []);

  const onExited = useCallback(
    (id: string) => {
      setOpenIds((prev) => {
        if (prev.has(id)) return prev;
        const descendants = descendantsMap.get(id) ?? [];
        if (!descendants.some((d) => prev.has(d))) return prev;
        const next = new Set(prev);
        for (const d of descendants) next.delete(d);
        return next;
      });
    },
    [descendantsMap],
  );

  const onExpandCollapse = useCallback(() => {
    setInstant(true);
    setOpenIds(allExpanded ? new Set() : new Set(allIds));
  }, [allExpanded, allIds]);

  useEffect(() => {
    if (!instant) return;
    const frame = requestAnimationFrame(() => setInstant(false));
    return () => cancelAnimationFrame(frame);
  }, [instant, openIds]);

  return (
    <div
      className={cn("relative text-xs leading-4 text-foreground", className)}
      style={{ "--rail": "24px", "--rail-inset": "4px", "--gap": "4px" } as CSSProperties}
    >
      <Branch
        nodes={nodes}
        depth={0}
        openIds={openIds}
        onToggle={onToggle}
        onExited={onExited}
        instant={instant}
        renderContent={renderContent}
      />
      {expandAll ? (
        <button
          type="button"
          onClick={onExpandCollapse}
          className={cn(
            "absolute top-0 right-0 z-20 cursor-pointer rounded-[2px] border-0 bg-transparent p-0",
            "font-medium leading-[18px] text-foreground transition-colors hover:text-muted-foreground",
            "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
          )}
        >
          {allExpanded ? "Collapse" : "Expand"}
        </button>
      ) : null}
    </div>
  );
}
A second branch can open while the first is still moving. Closing a parent is one collapse: its children wait until it's gone.

Every rail is one box per item. It used to be two boxes meeting at the tick, and on mobile Safari they landed on different pixels: a 1 to 2px stair-step at every branch.

Older stages step back

Readers open stages side by side to compare them. At full height, a third stage pushed the newest off screen. So among open stages, the latest stays full size and each earlier one halves.

Show code
/**
 * Among open siblings, the last one (in document order) stays full size and
 * each earlier open one halves: 1 → 0.5 → 0.25. Closed siblings don't count,
 * so closing the latest grows the one before it back to full.
 *
 * Pair it with an element that sizes from a CSS variable and transitions that
 * size on the same curve and duration as whatever opens it (400ms in RGL),
 * so a sibling shrinking and a panel opening read as one motion.
 */
export function siblingScale(
  id: string,
  siblingIds: readonly string[],
  openIds: ReadonlySet<string>,
  /** Each earlier open sibling is this fraction of the next. */
  step = 0.5,
): number {
  const open = siblingIds.filter((siblingId) => openIds.has(siblingId));
  const index = open.indexOf(id);
  if (index === -1) return 1;
  return step ** (open.length - 1 - index);
}
The newest open stage is always full size. Three open stages take 1.75 band heights instead of 3.

Bands resize over 400ms, the same as the panel spring, so opening one stage and stepping back the others read as one motion.

What shipped

rglpractice.com is one route: six projects, three stages each, plus the practice's information and a radio behind the wordmark.

Everything is edited in Sanity. Adding a project is a Studio entry, not a deploy, and renaming a branch never closes what a reader has open, because branches are keyed by slug, not label.

The cost is real. Four numbers live twice, once in JavaScript and once in CSS, and the site breaks quietly if they drift. An empty stage still shows a branch that opens onto nothing. Both are written down in the handoff, with the fix for each.

Learnings

Let people keep things open

I tried to make the tree tidy twice. First as an accordion in the third prototype, then as a Focus mode in August that kept one branch open and dimmed the rest. Both times it hid the comparison the site exists for, and both times I took it out. Now nothing closes unless the reader closes it.

The repo ships with a principles document: the rules the site holds itself to, why the strange parts are strange, and an honest list of loose ends. The next person to touch it starts from the reasons, not just the code.

Components

shadcn CLI
npx shadcn@latest add \
  https://benjaminuribe.com/r/branch-tree.json \
  https://benjaminuribe.com/r/sibling-scale.json