Data View Morph
One dataset across list, table, board, and card layouts — every entry morphs continuously to its new position with a shared-element animation, preserving the user's spatial memory.
This is a WebberUI Pro component
Free during the launch campaign: sign up or sign in, then hit “Copy install command” in the preview above and it installs straight away — no payment, no credit card. The command below returns 401 while you are signed out.
npx shadcn@latest add "https://webberui.com/r/data-view-morph.json?t=<install token>"Playground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<DataViewMorph />
Installation
npx shadcn@latest add "https://webberui.com/r/data-view-morph.json?t=<install token>"Or, once registries are configured in components.json, install it as @webberui/data-view-morph.
Usage
import { DataViewMorph, type DataViewItem } from "@/components/ui/data-view-morph";
const items: DataViewItem[] = [
{ id: "auth", title: "Sign-in flow revamp", subtitle: "OAuth", group: "doing", value: 8 },
{ id: "billing", title: "Billing page rewrite", subtitle: "Annual plans", group: "todo", value: 5 },
{ id: "export", title: "Data export", subtitle: "CSV / PDF", group: "done", value: 2 },
];
<DataViewMorph
items={items}
groups={[
{ id: "todo", label: "To do" },
{ id: "doing", label: "In progress" },
{ id: "done", label: "Done" },
]}
/>;All four layouts share the same items — on a switch, each entry uses its stable id as its layoutId to pair its before and after positions, so it flies and morphs continuously instead of fading out and being redrawn. The id must be stable (do not use the array index), otherwise the shared-element animation cannot pair entries correctly.
Controlled mode
When layout is omitted, the component ships its own toolbar and manages the layout state internally. Passing layout puts it in controlled mode, so you can supply your own triggers or sync it with URL state; pair it with showToolbar={false} to hide the built-in toolbar.
const [view, setView] = React.useState<DataViewLayout>("board");
<DataViewMorph
items={items}
layout={view}
onLayoutChange={setView}
showToolbar={false}
/>;Props
DataViewMorph
| Prop | Type | Default | Description |
|---|---|---|---|
items | DataViewItem[] | — | The dataset; each entry morphs continuously across the four layouts with a shared-element animation |
layout | DataViewLayout | — | Controlled layout; when provided, the outside drives it and the internal state is disabled |
defaultLayout | DataViewLayout | "list" | Initial layout in uncontrolled mode |
onLayoutChange | (layout: DataViewLayout) => void | — | Fires on a layout switch (called in both controlled and uncontrolled modes) |
layouts | DataViewLayout[] | all four | Which layouts the toolbar shows, and in what order |
groups | DataViewGroup[] | derived from items | Board column order and headings |
showToolbar | boolean | true | Whether to render the built-in layout-switching toolbar |
headings | Partial<Record<"title" | "group" | "value", string>> | — | Overrides for the three table column headers |
className | string | — | Appended to the outermost container's className |
DataViewItem
| Field | Type | Description |
|---|---|---|
id | string | Stable unique identifier; the shared-element animation pairs the same entry by it |
title | string | Primary title |
subtitle | string | Secondary line (optional) |
group | string | Grouping key: the board splits columns by it, the other layouts show it as a group label |
value | string | number | Quantitative field (table value column, card/board corner badge; optional) |
media | React.ReactNode | Leading media; falls back to the first character of the title when omitted (optional) |
DataViewLayout
"list" | "table" | "board" | "grid" — list, table, board, and card grid respectively.
How it works
- Shared-element morph: each entry uses its stable
idas a MotionlayoutId.LayoutGroupcoordinates the switch, and the entry flies and scales from its old bounding box to its new position on a spring, preserving the user's spatial memory - No stretching: the outer frame animates its size while the inner content uses
layout="position"so it is only repositioned, never scaled — text and media do not distort during large morphs, and the media frame keeps the same size in all four layouts - Board grouping: columns are split by
group, with column order and headings taken fromgroupsor derived from the data; each column header shows its item count, and group accent colors (dot / label) cycle by column order - Instance isolation:
layoutIds are namespaced by the component'suseId()(theLayoutGroup id), so multipleDataViewMorphinstances on one page never attract each other's entries - Table alignment: the three columns use a fixed-width grid —
1fris what makes cells line up across rows; the group and value column widths are fixed
Accessibility
- The built-in toolbar is a
radiogroupand each layout button is aradio, with arrow keys (←→↑↓) and Home/End switching layouts and moving focus (roving tabindex) - A layout switch announces the current view name through
aria-live="polite" - The data region is a semantic list (
role="list"/listitem); each board column is arole="group"with anaria-label, and the group color dots are decorative and markedaria-hidden - When the user has "reduce motion" enabled at the system level, the shared-element animation completes instantly (entries jump straight to their new positions) and the DOM structure stays identical to the normal state
Data Duel Split
A page-scale A/B duel split screen — scrolling reveals the metrics round by round while the divider in the middle slides like a tug-of-war rope and the running score at the bottom jumps in sync.
Hero Condense Header
As the page scrolls, the hero's title, avatar, and meta migrate one by one into a sticky header via shared-element FLIP, then expand back into place in order when you scroll up.