Card Spread

beta

A stack of cards that fans into an arc, row, corner fan, stamp arc, cascade, dealt hand or wheel on hover, focus or click.

beach
hills
forest
woods
valley

Installation

Terminal
pnpm dlx @dowel-ui/cli add card-spread

npm packages installed: class-variance-authority, radix-ui.

Accessibility

The deck is a role="group"; name it with aria-label or aria-labelledby. A deck of non-interactive cards gets a real toggle button (aria-pressed, named by `toggleLabel`, default "Spread cards") laid over the stack: it is the tab stop, Enter/Space/click/tap pin the spread open, and Escape closes it. It is a sibling of the cards, so every card's own content stays in the accessibility tree. A deck whose cards are links or buttons (via `asChild`) has no toggle and is reached through them. Keyboard focus anywhere inside opens the spread. Under reduced motion the layout still applies, instantly. In RTL the spread mirrors toward the inline start.

Props

CardSpread

PropTypeDefault
defaultOpen

Initial pinned-open state when uncontrolled.

booleanfalse
edge

Card edge for every item. Defaults to stamp for the stamp layout.

"rounded" | "stamp"
layout

How the cards fan out. arc with seven children is amicro's ARC (7 Cards).

CardSpreadLayout"arc"
onOpenChange

Called when the toggle (click, tap, Enter, Space or Escape) pins or unpins the spread.

(open: boolean) => void
open

Controlled pinned-open state. Hover and focus still open it transiently.

boolean
size"sm" | "md" | "lg""md"
stagger

Milliseconds between one card starting to move and the next. Scaled for reduced motion.

number30
toggleLabel

Accessible name of the pin toggle a deck of non-interactive cards gets.

string"Spread cards"
trigger

What opens the spread. - hover (default): hover, keyboard focus anywhere inside, or pinned by the deck's toggle. - click: only the toggle. - manual: only the open prop; no toggle is rendered.

CardSpreadTrigger"hover"

Plus every attribute of <div>.

CardSpreadItem

PropTypeDefault
edge"rounded" | "stamp""rounded"
asChild

Render the card surface as your own element — an <a> or <button> for a card that goes somewhere or does something.

boolean

Plus every attribute of <div>.

Quality

10/10 checks, measured from the source and its tests

  • Testedpasses
  • axe assertionpasses
  • Keyboard testedpasses
  • Storybook examplespasses
  • Accessibility documentedpasses
  • Semantic tokens onlypasses
  • Motion from tokenspasses
  • className mergedpasses
  • Visible focuspasses
  • No fixed widthspasses

Source

This is exactly what dowel add card-spread writes into your project, with imports rewritten to your own path alias.

ui/card-spread.tsx
"use client";

// Ported from amicro "Card Spreads" (MIT, © 2026 Syed Subhan Uddin). See THIRD_PARTY_NOTICES.md.
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import {
  Children,
  createContext,
  Fragment,
  isValidElement,
  useCallback,
  useContext,
  useEffect,
  useRef,
  useState,
  type ComponentPropsWithRef,
  type CSSProperties,
  type FocusEvent,
  type KeyboardEvent,
  type PointerEvent,
  type ReactElement,
  type ReactNode,
} from "react";

import { focusRing } from "@/lib/styles";
import { cn } from "@/lib/utils";

import {
  getCardSpreadOrigin,
  getCardSpreadPose,
  type CardSpreadLayout,
  type CardSpreadTuning,
} from "./card-spread-layouts";

/*
 * A deck that fans out (ADR 0014: one mechanism, a `layout` axis for every
 * amicro spread). amicro animates each card with a spring from `motion`; the
 * cards here only ever travel between two known poses, so a CSS transition on
 * `transform` with the overshoot ease does the same job, and the duration and
 * per-card stagger run through the motion scale: under reduced motion the
 * layout still applies, instantly. This is decoration, never an indicator.
 *
 * The spread is visual only: every card is in the accessibility tree whether
 * the deck is open or closed. The only state worth reporting is whether the
 * deck is pinned open, and that belongs to the toggle, as aria-pressed.
 */

const PREFIX = "dowel-card-spread";

/*
 * Only the direction flip lives in the stylesheet: horizontal travel and
 * rotation are multiplied by it, so in RTL the first card fans toward the
 * inline start and the corner fan pivots on the bottom-right. Two rules,
 * because a browser without :dir() would drop a combined selector outright.
 */
const STYLES = `
[data-slot=card-spread]{--card-spread-flip:1}
[dir=rtl] [data-slot=card-spread]{--card-spread-flip:-1}
[data-slot=card-spread]:dir(rtl){--card-spread-flip:-1}
[data-slot=card-spread]:dir(ltr){--card-spread-flip:1}
`;

const FOCUSABLE =
  'a[href], button, input, select, textarea, summary, [tabindex]:not([tabindex="-1"])';

/** The deck: sized like one card, with the spread overflowing it. */
const cardSpreadVariants = cva("relative isolate shrink-0", {
  variants: {
    /** The card size. Override with `className` (`w-* h-*`) for anything else. */
    size: {
      sm: "h-32 w-24",
      md: "h-44 w-32",
      lg: "h-56 w-40",
    },
  },
  defaultVariants: {
    size: "md",
  },
});

/** A card's surface. */
const cardSpreadItemVariants = cva(
  "block size-full overflow-hidden bg-muted text-muted-foreground",
  {
    variants: {
      /**
       * `rounded` is a plain card. `stamp` perforates all four edges with a CSS
       * mask — the Stamp Arc's postage stamps.
       */
      edge: {
        rounded: "rounded-2xl border border-border shadow-md",
        stamp: "rounded-none",
      },
    },
    defaultVariants: {
      edge: "rounded",
    },
  },
);

/**
 * Perforations: a circle punched out every `--card-spread-hole` + gap along
 * each edge, and a solid block through the middle. The mask only reads alpha,
 * so `black` here is opacity, not a colour.
 */
const STAMP_MASK = [
  "radial-gradient(var(--card-spread-hole), transparent 98%, black) round " +
    "calc(-1.5 * var(--card-spread-hole) - 0.1875rem) calc(-1.5 * var(--card-spread-hole) - 0.1875rem) / " +
    "calc(3 * var(--card-spread-hole) + 0.375rem) calc(3 * var(--card-spread-hole) + 0.375rem)",
  "linear-gradient(black 0 0) no-repeat 50% / " +
    "calc(100% - 3 * var(--card-spread-hole) - 0.375rem) calc(100% - 3 * var(--card-spread-hole) - 0.375rem)",
].join(", ");

const STAMP_STYLE = {
  "--card-spread-hole": "0.3125rem",
  mask: STAMP_MASK,
  WebkitMask: STAMP_MASK,
} as CSSProperties;

export type CardSpreadTrigger = "hover" | "click" | "manual";

interface SpreadContextValue {
  layout: CardSpreadLayout;
  count: number;
  open: boolean;
  tuning: CardSpreadTuning;
  stagger: number;
  edge: "rounded" | "stamp";
}

/**
 * The cards, in deck order.
 *
 * A card's position comes from its index among its siblings, so the cards must
 * be children of the spread itself. Fragments are opened up, because
 * `<>{a}{b}</>` is still a list of cards; anything else is counted as one card,
 * and a wrapper component that renders several cards would put them all at
 * index 0 on top of each other — so development builds say so (see
 * CardSpread).
 */
function flattenItems(children: ReactNode): ReactElement[] {
  const items: ReactElement[] = [];
  for (const child of Children.toArray(children)) {
    if (!isValidElement<{ children?: ReactNode }>(child)) continue;
    if (child.type === Fragment) {
      items.push(...flattenItems(child.props.children));
      continue;
    }
    items.push(child);
  }
  return items;
}

const SpreadContext = createContext<SpreadContextValue | null>(null);
const IndexContext = createContext(0);

export interface CardSpreadProps
  extends
    ComponentPropsWithRef<"div">,
    VariantProps<typeof cardSpreadVariants>,
    CardSpreadTuning {
  /** How the cards fan out. `arc` with seven children is amicro's ARC (7 Cards). */
  layout?: CardSpreadLayout;
  /** Controlled pinned-open state. Hover and focus still open it transiently. */
  open?: boolean;
  /** Initial pinned-open state when uncontrolled. */
  defaultOpen?: boolean;
  /** Called when the toggle (click, tap, Enter, Space or Escape) pins or unpins the spread. */
  onOpenChange?: (open: boolean) => void;
  /**
   * What opens the spread.
   *
   * - `hover` (default): hover, keyboard focus anywhere inside, or pinned by
   *   the deck's toggle.
   * - `click`: only the toggle.
   * - `manual`: only the `open` prop; no toggle is rendered.
   */
  trigger?: CardSpreadTrigger;
  /** Milliseconds between one card starting to move and the next. Scaled for reduced motion. */
  stagger?: number;
  /** Card edge for every item. Defaults to `stamp` for the stamp layout. */
  edge?: "rounded" | "stamp";
  /** Accessible name of the pin toggle a deck of non-interactive cards gets. */
  toggleLabel?: string;
}

/** A stack of cards that fans out into a layout on hover, focus, or when pinned. */
export function CardSpread({
  className,
  children,
  layout = "arc",
  size,
  open: openProp,
  defaultOpen = false,
  onOpenChange,
  trigger = "hover",
  stagger = 30,
  edge,
  toggleLabel = "Spread cards",
  arc,
  gap,
  offset,
  ref,
  onPointerEnter,
  onPointerLeave,
  onPointerDown,
  onFocus,
  onBlur,
  ...props
}: CardSpreadProps) {
  const [uncontrolled, setUncontrolled] = useState(defaultOpen);
  const controlled = openProp !== undefined;
  const pinned = controlled ? openProp : uncontrolled;
  const [hovered, setHovered] = useState(false);
  const [focused, setFocused] = useState(false);
  const [interactiveItems, setInteractiveItems] = useState(false);
  const rootRef = useRef<HTMLDivElement | null>(null);
  // Focus that follows a pointer press is not a keyboard user arriving: a
  // click pins or unpins, and must not be masked by focus holding it open.
  const pressing = useRef(false);

  const open = pinned || (trigger === "hover" && (hovered || focused));

  const setPinned = useCallback(
    (next: boolean) => {
      if (!controlled) setUncontrolled(next);
      onOpenChange?.(next);
    },
    [controlled, onOpenChange],
  );

  // A deck of links or buttons is reached, and opened, through them. A deck
  // of pictures gets a real toggle button instead, or a keyboard user could
  // never open it. The toggle is a sibling of the cards, not their parent,
  // so every card's own content stays in the accessibility tree.
  useEffect(() => {
    const cards = rootRef.current?.querySelectorAll('[data-slot="card-spread-item"]') ?? [];
    const has = Array.from(cards).some((card) => card.querySelector(FOCUSABLE) != null);
    if (has !== interactiveItems) setInteractiveItems(has);
  }, [children, interactiveItems]);

  const setRef = useCallback(
    (node: HTMLDivElement | null) => {
      rootRef.current = node;
      if (typeof ref === "function") ref(node);
      else if (ref) ref.current = node;
    },
    [ref],
  );

  function handleToggleKeyDown(event: KeyboardEvent<HTMLButtonElement>) {
    if (event.key !== "Escape" || !open) return;
    setFocused(false);
    setHovered(false);
    if (pinned) setPinned(false);
  }

  function handlePointerEnter(event: PointerEvent<HTMLDivElement>) {
    onPointerEnter?.(event);
    // A tap is a click, not a hover: touch pins with the toggle instead.
    if (event.pointerType !== "touch") setHovered(true);
  }

  function handlePointerLeave(event: PointerEvent<HTMLDivElement>) {
    onPointerLeave?.(event);
    setHovered(false);
  }

  function handlePointerDown(event: PointerEvent<HTMLDivElement>) {
    onPointerDown?.(event);
    pressing.current = true;
  }

  function handleFocus(event: FocusEvent<HTMLDivElement>) {
    onFocus?.(event);
    if (!pressing.current) setFocused(true);
    pressing.current = false;
  }

  function handleBlur(event: FocusEvent<HTMLDivElement>) {
    onBlur?.(event);
    if (!event.currentTarget.contains(event.relatedTarget)) setFocused(false);
  }

  const items = flattenItems(children);
  const foreignChild = items.some((item) => item.type !== CardSpreadItem);

  useEffect(() => {
    if (process.env.NODE_ENV === "production" || !foreignChild) return;
    console.warn(
      "CardSpread: every child should be a <CardSpreadItem>. A component that renders " +
        "several cards is counted as one, so its cards stack at the same position.",
    );
  }, [foreignChild]);
  const context: SpreadContextValue = {
    layout,
    count: items.length,
    open,
    tuning: { arc, gap, offset },
    stagger,
    edge: edge ?? (layout === "stamp" ? "stamp" : "rounded"),
  };

  return (
    <>
      <style href={PREFIX} precedence="dowel">
        {STYLES}
      </style>
      <div
        ref={setRef}
        role="group"
        data-slot="card-spread"
        data-layout={layout}
        data-state={open ? "open" : "closed"}
        data-pinned={pinned ? "" : undefined}
        className={cn(cardSpreadVariants({ size }), className)}
        onPointerEnter={handlePointerEnter}
        onPointerLeave={handlePointerLeave}
        onPointerDown={handlePointerDown}
        onFocus={handleFocus}
        onBlur={handleBlur}
        {...props}
      >
        <SpreadContext value={context}>
          {items.map((item, index) => (
            <IndexContext key={item.key ?? index} value={index}>
              {item}
            </IndexContext>
          ))}
        </SpreadContext>
        {trigger !== "manual" && !interactiveItems ? (
          <button
            type="button"
            data-slot="card-spread-toggle"
            aria-pressed={pinned}
            aria-label={toggleLabel}
            style={{ zIndex: items.length + 1 }}
            className={cn("absolute inset-0 cursor-pointer rounded-2xl", focusRing)}
            onClick={() => {
              setPinned(!pinned);
            }}
            onKeyDown={handleToggleKeyDown}
          />
        ) : null}
      </div>
    </>
  );
}

export interface CardSpreadItemProps
  extends ComponentPropsWithRef<"div">, VariantProps<typeof cardSpreadItemVariants> {
  /**
   * Render the card surface as your own element — an `<a>` or `<button>` for
   * a card that goes somewhere or does something.
   */
  asChild?: boolean;
}

/** One card. Its position comes from its index in the deck. */
export function CardSpreadItem({
  className,
  asChild,
  edge,
  style,
  ...props
}: CardSpreadItemProps) {
  const spread = useContext(SpreadContext);
  const index = useContext(IndexContext);
  if (!spread) throw new Error("<CardSpreadItem> must be rendered inside <CardSpread>.");

  const { layout, count, open, tuning, stagger } = spread;
  const pose = getCardSpreadPose(layout, index, count, open, tuning);
  const origin = getCardSpreadOrigin(layout);
  const flip = "var(--card-spread-flip, 1)";
  // Opening deals from the first card; closing gathers from the last.
  const order = open ? index : count - 1 - index;
  const surfaceEdge = edge ?? spread.edge;

  const position: CSSProperties = {
    zIndex: pose.z,
    transform:
      `translate(calc(${String(pose.x)}% * ${flip}), ${String(pose.y)}%) ` +
      `rotate(calc(${String(pose.rotate)}deg * ${flip})) scale(${String(pose.scale)})`,
    transformOrigin: `calc(50% + ${String(origin.x)}% * ${flip}) ${String(origin.y)}%`,
    transitionDelay: `calc(${String(order * stagger)}ms * var(--motion-scale, 1))`,
  };

  const Surface = asChild ? Slot.Root : "div";

  return (
    <div
      data-slot="card-spread-item"
      data-state={open ? "open" : "closed"}
      data-index={index}
      style={position}
      className={cn(
        "absolute inset-0 transition-transform duration-[var(--duration-slower)] ease-[var(--ease-overshoot)]",
        "data-[state=closed]:ease-[var(--ease-out-quint)]",
        // The mask would clip a ring drawn on the stamp itself, so it is drawn here.
        surfaceEdge === "stamp" &&
          "drop-shadow-md has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-ring",
      )}
    >
      <Surface
        data-slot="card-spread-card"
        data-edge={surfaceEdge}
        style={surfaceEdge === "stamp" ? { ...STAMP_STYLE, ...style } : style}
        className={cn(cardSpreadItemVariants({ edge: surfaceEdge }), focusRing, className)}
        {...props}
      />
    </div>
  );
}

export { cardSpreadItemVariants, cardSpreadVariants };
ui/card-spread-layouts.ts
// Ported from amicro "Card Spreads" (MIT, © 2026 Syed Subhan Uddin). See THIRD_PARTY_NOTICES.md.

/*
 * The geometry of every spread, as pure functions of (index, count).
 *
 * amicro hard-codes each spread for five (or seven) cards in pixels, for a
 * card 128 × 176 px. Here each is one formula, so any number of cards works,
 * and every distance is a percentage of the card itself — `translate(%)`
 * resolves against the element's own box — so a spread keeps its proportions
 * at any card size. The defaults reproduce amicro's positions to within a few
 * pixels at five cards.
 *
 * Units, in every pose:
 * - `x` is a percentage of the card's width (positive = toward the inline end
 *   once the component applies its direction flip),
 * - `y` a percentage of the card's height (positive = down),
 * - `rotate` degrees (positive = clockwise in LTR),
 * - `scale` a factor, `z` a stacking order.
 */

export const CARD_SPREAD_LAYOUTS = [
  "arc",
  "long-arc",
  "linear",
  "corner",
  "stamp",
  "cascade",
  "scatter",
  "wheel",
] as const;

export type CardSpreadLayout = (typeof CARD_SPREAD_LAYOUTS)[number];

export interface CardSpreadPose {
  x: number;
  y: number;
  rotate: number;
  scale: number;
  z: number;
}

/**
 * The three knobs amicro's adjustable Stamp Arc exposes, generalised to every
 * layout. Each layout reads the ones that mean something to it.
 */
export interface CardSpreadTuning {
  /** Rotation between neighbouring cards, in degrees. */
  arc?: number;
  /** Horizontal distance between neighbouring cards, as a percentage of card width. */
  gap?: number;
  /**
   * Vertical travel, as a percentage of card height. For the curved layouts it
   * is the curvature: a card `d` steps from the centre drops `offset × d²`.
   * For `cascade` it is the rise per card.
   */
  offset?: number;
}

export interface CardSpreadOrigin {
  /** Horizontal offset of the transform origin from the card's centre, in % of its width. */
  x: number;
  /** Vertical transform origin, in % of the card's height from its top edge. */
  y: number;
}

interface LayoutSpec {
  defaults: Required<CardSpreadTuning>;
  origin: CardSpreadOrigin;
  open: (d: number, i: number, count: number, t: Required<CardSpreadTuning>) => CardSpreadPose;
  rest?: (d: number, i: number, count: number) => Partial<CardSpreadPose>;
}

const BOTTOM: CardSpreadOrigin = { x: 0, y: 100 };
const CENTRE: CardSpreadOrigin = { x: 0, y: 50 };

/** Stacking order that keeps the centre card on top and falls off symmetrically. */
function centred(d: number, count: number): number {
  return Math.round(count - Math.abs(d) * 2);
}

/** The card sitting exactly at the centre, if there is one (odd counts only). */
function isCentre(d: number): boolean {
  return Math.abs(d) < 0.5;
}

/**
 * The shared shape of amicro's arcs: a fixed step in angle and distance, and a
 * parabolic drop. `lift` is how far the centre card rises, in units of
 * `offset × centre`; it is what separates the ARC's steep curve from the Long
 * ARC's and Stamp's flatter one.
 */
function arcPose(lift: number): LayoutSpec["open"] {
  return (d, _i, count, t) => {
    const centre = (count - 1) / 2;
    return {
      x: d * t.gap,
      y: t.offset * (d * d - lift * centre),
      rotate: d * t.arc,
      scale: isCentre(d) ? 1.05 : 1,
      z: centred(d, count),
    };
  };
}

/**
 * Small fixed offsets that make the dealt hand look dealt rather than ruled.
 * Deterministic — the same card always lands in the same place — and taken
 * from the difference between amicro's hand-placed coordinates and the
 * formula, so five cards land where amicro put them.
 */
const SCATTER_JITTER: readonly { x: number; y: number; r: number }[] = [
  { x: -1, y: -1.5, r: 0 },
  { x: 2, y: 1.75, r: 1 },
  { x: 0, y: 0, r: 2 },
  { x: -2, y: 4.5, r: 1 },
  { x: 1, y: 1.5, r: 1 },
];

const LAYOUTS: Record<CardSpreadLayout, LayoutSpec> = {
  // ARC (5 Cards) and ARC (7 Cards): 15° and ~36 px per step, a 5 px/step² curve.
  arc: {
    defaults: { arc: 15, gap: 28, offset: 2.8 },
    origin: BOTTOM,
    open: arcPose(1),
  },
  // Long ARC (5 Cards): half the angle, twice the reach.
  "long-arc": {
    defaults: { arc: 7.5, gap: 55, offset: 3.5 },
    origin: BOTTOM,
    open: arcPose(0.4),
  },
  // Linear Spread: a row, no rotation, no curve.
  linear: {
    defaults: { arc: 0, gap: 35, offset: 0 },
    origin: CENTRE,
    open: (d, _i, count, t) => ({
      x: d * t.gap,
      y: t.offset * d * d,
      rotate: d * t.arc,
      scale: isCentre(d) ? 1.05 : 1,
      z: centred(d, count),
    }),
  },
  // Corner Fan: pivots on the bottom inline-start corner, first card a step back.
  corner: {
    defaults: { arc: 10, gap: 0, offset: 0 },
    origin: { x: -50, y: 100 },
    open: (_d, i, count, t) => ({
      x: i * t.gap,
      y: i * t.offset,
      rotate: (i - 1) * t.arc,
      scale: i === Math.floor((count - 1) / 2) ? 1.03 : 1,
      z: count - i,
    }),
  },
  // Stamp Arc (Adjustable): the widest, flattest arc; arc/gap/offset are its sliders.
  stamp: {
    defaults: { arc: 12.5, gap: 70, offset: 7.1 },
    origin: BOTTOM,
    open: arcPose(0.4),
  },
  // Cascade Stagger Fan: climbs diagonally, each card a step up and across.
  cascade: {
    defaults: { arc: 6, gap: 11, offset: 16 },
    origin: CENTRE,
    open: (d, _i, count, t) => ({
      x: d * t.gap,
      y: -d * t.offset - t.offset / 2,
      rotate: d * t.arc,
      scale: isCentre(d) ? 1.05 : 0.98,
      z: centred(d, count),
    }),
    // Closed, the deck shows its edges: each card sits a hair below the last.
    rest: (d) => ({ y: d * 1.1 }),
  },
  // Scatter Desk Deal: an overlapping, slightly irregular dealt hand.
  scatter: {
    defaults: { arc: 7, gap: 29, offset: 6.75 },
    origin: CENTRE,
    open: (d, i, count, t) => {
      const jitter = SCATTER_JITTER[i % SCATTER_JITTER.length] ?? { x: 0, y: 0, r: 0 };
      return {
        x: d * t.gap + jitter.x,
        y: t.offset * d * d - 17 + jitter.y,
        rotate: d * t.arc + jitter.r,
        scale: isCentre(d) ? 1.05 : 0.98,
        z: centred(d, count),
      };
    },
  },
  // Wheel Radial Fan: rotates about a hub just below the cards.
  wheel: {
    defaults: { arc: 18, gap: 0, offset: 2.8 },
    origin: { x: 0, y: 110 },
    open: (d, _i, count, t) => {
      const centre = (count - 1) / 2;
      return {
        x: d * t.gap,
        y: t.offset * (d * d - centre * centre - 1.6),
        rotate: d * t.arc,
        scale: isCentre(d) ? 1.05 : 0.98,
        z: centred(d, count),
      };
    },
  },
};

/** The tuning a layout uses when none is given. */
export function getCardSpreadDefaults(layout: CardSpreadLayout): Required<CardSpreadTuning> {
  return { ...LAYOUTS[layout].defaults };
}

/** Where a layout's cards pivot. */
export function getCardSpreadOrigin(layout: CardSpreadLayout): CardSpreadOrigin {
  return { ...LAYOUTS[layout].origin };
}

/**
 * The pose of card `index` of `count`, open or closed.
 *
 * Undefined tuning values fall back to the layout's defaults, so a consumer
 * can adjust one knob without restating the others.
 */
export function getCardSpreadPose(
  layout: CardSpreadLayout,
  index: number,
  count: number,
  open: boolean,
  tuning: CardSpreadTuning = {},
): CardSpreadPose {
  const spec = LAYOUTS[layout];
  const d = index - (count - 1) / 2;

  if (!open) {
    const base: CardSpreadPose = {
      x: 0,
      y: 0,
      rotate: 0,
      scale: 1,
      z: layout === "corner" ? count - index : centred(d, count),
    };
    return { ...base, ...spec.rest?.(d, index, count) };
  }

  const t: Required<CardSpreadTuning> = {
    arc: tuning.arc ?? spec.defaults.arc,
    gap: tuning.gap ?? spec.defaults.gap,
    offset: tuning.offset ?? spec.defaults.offset,
  };
  return spec.open(d, index, count, t);
}