Swappable
A headless rotating grid. Hand it an array of items and a grid shape (rows ×
cols); it renders the first rows × cols items, then, while
items.length > gridSize, periodically swaps a random visible cell with a
random off-screen item. Uniqueness is preserved: the same item is never
visible twice.
import { Swappable } from "@togetheragency/ui/swappable";
type Logo = (typeof Logos)[number];
export function LogoWall() {
return (
<Swappable.Root
items={Logos}
rows={1}
cols={{ base: 2, sm: 3, md: 4, lg: 5 }}
className="rounded-2xl border bg-background p-3"
>
<Swappable.Grid<Logo> className="gap-3">
{(Logo) => (
<Swappable.Item>
<div className="flex h-24 items-center justify-center rounded-xl border bg-card p-4">
<Logo />
</div>
</Swappable.Item>
)}
</Swappable.Grid>
</Swappable.Root>
);
}Import
import { Swappable } from "@togetheragency/ui/swappable";The component is exported as a compound object; every sub-component is a
property of Swappable:
Swappable.Root;
Swappable.Grid;
Swappable.Item;Composition
Swappable is three parts that work together; Root owns state and the
rotation timer, Grid renders the visible cells via a render prop, and Item
is the animated cell wrapper:
<Swappable.Root items={items} rows={1} cols={{ base: 2, md: 4 }}>
<Swappable.Grid<Item>>
{(item) => (
<Swappable.Item>
<Card data={item} />
</Swappable.Item>
)}
</Swappable.Grid>
</Swappable.Root>Swappable.Rootholds theitemsarray, the grid shape, the rotation timer, and the default motion variants. It deduplicatesitemson entry and assigns each one a stable internal id (via aMap<T, string>keyed by reference for objects, by value for primitives), so consumers never have to thread anidfield through their data.Swappable.Gridrenders the responsive CSS grid and calls its render prop once per visible cell. It injects the internal id askeyon the rendered element, which is what letsAnimatePresenceorchestrate the enter/exit animation when an item swaps in or out. It is generic over the item type —<Swappable.Grid<MyItem>>so the render-prop argument is typed.Swappable.Itemis amotion.divthat inherits Root’s defaultinitial/animate/exit/transition. Per-instance props win over the defaults.
Items are assumed to be unique. Duplicates are removed on entry by reference equality (objects, functions, arrays) or value equality (strings, numbers). If you pass primitives, make sure each value appears once — duplicate primitives will be silently collapsed.
Usage
Basic usage
The default behavior; a single row, responsive column count, rotation every 2–5 seconds, and a scale-and-fade transition.
<Swappable.Root
items={Logos}
rows={1}
cols={{ base: 2, sm: 3, md: 4, lg: 5 }}
>
<Swappable.Grid<Logo>>
{(Logo) => (
<Swappable.Item>
<Logo />
</Swappable.Item>
)}
</Swappable.Grid>
</Swappable.Root>Multi-row grid with pause on hover
Pass rows to get a 2D grid. The total cell count is rows × resolvedCols
where resolvedCols is chosen from the cols map for the current viewport.
pauseOnHover freezes the rotation timer while the grid is hovered or
focused; useful for letting users actually read what’s on screen.
<Swappable.Root
items={Logos}
rows={2}
cols={{ base: 2, sm: 3, md: 4 }}
rotationInterval={{ min: 1500, max: 3500 }}
pauseOnHover
>
<Swappable.Grid<Logo>>
{(Logo) => (
<Swappable.Item>
<Logo />
</Swappable.Item>
)}
</Swappable.Grid>
</Swappable.Root>Fast rotation
rotationInterval accepts a { min, max } range in milliseconds; each tick
picks a random delay inside that range.
<Swappable.Root
items={Logos}
rows={1}
cols={{ base: 3, sm: 4, lg: 6 }}
rotationInterval={{ min: 600, max: 1400 }}
>
<Swappable.Grid<Logo>>
{(Logo) => (
<Swappable.Item>
<Logo />
</Swappable.Item>
)}
</Swappable.Grid>
</Swappable.Root>Custom transition
The motion variants applied to every cell are exposed on Swappable.Root as
initial, animate, exit, and transition. They are forwarded verbatim
to the underlying motion.div inside each Swappable.Item, so anything
motion supports (translate, scale, blur, color, easing curves, springs) is
available.
<Swappable.Root
items={Logos}
rows={1}
cols={{ base: 2, sm: 4, lg: 5 }}
rotationInterval={{ min: 1200, max: 2800 }}
initial={{ opacity: 0, y: 14, filter: "blur(6px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={{ opacity: 0, y: -14, filter: "blur(6px)" }}
transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
>
<Swappable.Grid<Logo>>
{(Logo) => (
<Swappable.Item>
<Logo />
</Swappable.Item>
)}
</Swappable.Grid>
</Swappable.Root>You can also override the transition on a single Swappable.Item — per-item
props take precedence over the Root defaults. This is useful when one cell
needs a different feel (an accent cell, a hero logo, etc.).
<Swappable.Item
initial={{ opacity: 0, rotate: -8 }}
animate={{ opacity: 1, rotate: 0 }}
exit={{ opacity: 0, rotate: 8 }}
transition={{ type: "spring", stiffness: 220, damping: 24 }}
>
<Logo />
</Swappable.Item>Static grid (no rotation)
When items.length <= rows × cols, every item fits on screen, so rotation is
disabled automatically — the grid renders statically and AnimatePresence
never kicks in. No prop needed.
<Swappable.Root
items={Logos.slice(0, 4)}
rows={1}
cols={{ base: 2, sm: 4 }}
>
<Swappable.Grid<Logo>>
{(Logo) => (
<Swappable.Item>
<Logo />
</Swappable.Item>
)}
</Swappable.Grid>
</Swappable.Root>Reduced motion
When prefers-reduced-motion: reduce is set, the rotation timer never
starts — the initial grid renders and stays. The motion variants on
Swappable.Item are also short-circuited by motion’s built-in
reduced-motion handling. No extra prop is needed.
API Reference
Swappable.Root
Renders a <div> and owns the source items, the grid shape, the rotation
timer, and the default motion variants applied to every cell. Generic over
the item type T so items and the matching Swappable.Grid<T> render prop
stay strongly typed.
Data attributes: data-slot="swappable-root", data-swappable-instance (scoped id used by the generated grid CSS).
Swappable.Grid
Renders the responsive CSS grid and calls children once per visible cell.
Generic over the item type T — use <Swappable.Grid<MyItem>> so the
render-prop argument is typed.
Data attributes: data-slot="swappable-grid", data-swappable-grid (scoped id matched by the generated CSS).
Swappable.Grid clones the top-level element returned by children and
injects the internal item key. The cloned element is then handed to
AnimatePresence (with mode="popLayout"), which drives the enter/exit
animation. Returning a fragment as the top-level node will not animate —
the root must be a single React element (typically Swappable.Item).
Swappable.Item
Animated cell wrapper. Renders a motion.div and inherits the default
initial / animate / exit / transition from Swappable.Root. Any
prop you pass overrides the inherited default for that one cell.
Data attributes: data-slot="swappable-item".
Responsive columns
cols accepts either a single number or a mobile-first breakpoint map. The
breakpoints align with Tailwind’s defaults:
Internally the component ships a small scoped <style> block per instance
keyed by useId(), so the grid template responds to viewport changes via
CSS (no JS resize listener fires on every frame).
Behavior notes
- Rotation only runs when
items.length > rows × resolvedCols. With fewer items than cells, the grid renders statically. - Each swap picks a uniformly random visible cell and replaces it with a uniformly random off-screen item. The invariant “no duplicates on screen” is preserved across every swap.
- The rotation timer is paused while
pauseOnHoveris on and the grid is hovered or focused. The next tick fires when hover/focus releases. prefers-reduced-motion: reducedisables the rotation timer entirely.
Exported types
import type {
ResponsiveCols,
RotationInterval,
SwappableGridProps,
SwappableItemProps,
SwappableRootProps,
} from "@togetheragency/ui/swappable";