Skip to content
txbrendan/ui

View morph

beta

Moves the same shells from one layout to another. The shell moves, then both faces crossfade together.

Preview

Open
Ledgerly payouts
Kite Health articles
Atlas mobile

Install

  1. 1. Add the registry to components.json, once per app. Without it, the shadcn CLI looks up @txbrendan in a public list that txbrendan/ui does not control.

    npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"
  2. 2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.

    npx shadcn@latest add @txbrendan/theme
  3. 3. Add the component.

    npx shadcn@latest add @txbrendan/view-morph

Use it for

  • Two or more layouts of the same records, such as a board and a list, when each record must keep its element and its focus.
  • Put one ViewMorphItem per record, with the same id in every layout, and swap the slot inside it.
  • Put a ViewMorphGroup around each cluster. A header item uses layer="header". Its text is the same in every layout, and it does not crossfade.
  • Mark the text that should fade with data-flip-slot. Leave the header's text unmarked.

Avoid it for

  • A one-time entrance. The first paint stores positions and does not move.
  • Scaling a card to its next size. The shell animates its box, so a radius and a shadow are not stretched.
  • Records that appear and disappear between layouts. A shell with no previous box does not fly in.

Props

  • layout

    Which layout is showing. Changing it moves the shells.

    string · default "default"

  • contentLayouts

    When both sides of a change are in this list, each slot stays fully opaque and its children crossfade. Every other change crossfades the slot.

    string[] · default []

  • className

    Classes on the root. Later classes win.

    string

  • children

    Groups and shells.

    ReactNode

  • Part. ViewMorphGroup

    A group of shells. id is its stable key and is not copied onto the element's id. as picks "div", "ul", or "tbody".

    { id: string; as?: "div" | "ul" | "tbody"; className?: string; children?: ReactNode }

  • Part. ViewMorphItem

    One shell. id is its stable key and is not copied onto the element's id. The shell is not a tab stop. layer "header" stays above the others and is not crossfaded, so its text must be the same in every layout.

    { id: string; layer?: "item" | "header"; as?: "div" | "li" | "tr"; className?: string; children?: ReactNode }

Accessibility

  • The root has no role of its own. The page that uses it gives it one, such as a tab panel.
  • A shell is not in the tab order. Put the control that should take focus inside the shell.
  • The shell moves with transform only, on --motion-duration-slow and --motion-ease-out, then rests. The ease never exceeds 1, so a frame-stepped strip shows no overshoot. Width and height do not animate.
  • The fade follows --motion-duration-fast, capped at 120ms, and does not follow --motion-distance. A shell that moves waits until --motion-crossfade-delay. A shell that stays, and a reduced-motion switch, dissolve with no delay. That delay is the shared start. It is not a gap, and there is no row stagger.
  • A header is not crossfaded. Its text has to be byte-identical across layouts.
  • Motion off sets the durations to 0, and that drops the fade. Reduced motion sets --motion-distance to 0 and keeps the fade.

Examples

Two layouts of the same shells

<ViewMorph layout={layout}>
  <ViewMorphGroup id="open">
    <ViewMorphItem id="open-label" layer="header">
      <div>Open</div>
    </ViewMorphItem>
    <ViewMorphItem id="ledger">
      <div data-flip-slot>{layout === "board" ? "Ledgerly payouts" : "Ledgerly payouts, list"}</div>
    </ViewMorphItem>
  </ViewMorphGroup>
</ViewMorph>