Content Swap
Replace a loading placeholder with content and smoothly adjust the container’s height.
Use it wherever a region loads in place: a card of invoices, a profile, a chart panel, a filtered list. The skeleton drops away as the content resolves into focus, and the box morphs from one height to the other, so the page below moves instead of jumping. Don't use it for content that arrives in pieces, like rows that stream in one by one. Use Presence List for those.
Installation
npm i @youj/motionFor an app already set up with npx @youj/cli init, which installs the peers (@youj/core, @youj/motion, motion and Base UI) and wires the provider. New app? Start at Installation.
Usage
import { ContentSwap } from '@youj/motion'
import { Card } from '@youj/react'
export function Invoices({ invoices, isLoading }: { invoices: Invoice[]; isLoading: boolean }) {
return (
<Card>
<ContentSwap loading={isLoading} fallback={<InvoicesSkeleton />} loadingLabel="Loading invoices">
<InvoiceList invoices={invoices} />
</ContentSwap>
</Card>
)
}loading decides which side shows. fallback stands in while it's true, and children is the content. loadingLabel adds a polite announcement while loading, off by default.
Examples
Shape the skeleton like the content
The swap reads as one list resolving when the fallback has the content's shape: the same rows, avatars and column widths. When the content has more rows than the skeleton, as in the preview, the box grows the difference as the rows resolve in. Any skeleton works. The one in these examples is @youj/react's Skeleton, which times its shimmer on the provider's Speed and stops it under reduced motion.
Refetching
It works in both directions. Set loading back to true and the skeleton returns, and a swap that starts mid-morph continues from the current height. Change the filter below a few times in a row.
Every filter here refetches, so each result replaces the skeleton with a different height: five rows, three, then one. The rows below the card would follow the same curve.
Failed loads
The content side is whatever the request settled on, so an error swaps in just as data does: here the first request fails, and Try again brings the skeleton back before the domains resolve.
Give the message role="alert" so it's announced as it appears. The retry button leaves with the error, and the outgoing side is inert, so move focus into the region before you refetch.
Reference
Propsloading, fallback, children… (4)
Props
| Prop | Type | |
|---|---|---|
loadingRequired | booleanShow the fallback instead of the content. | – |
fallbackRequired | ReactNodeStands in while loading: usually a skeleton shaped like the content. Hidden from assistive tech. | – |
loadingLabel | stringAnnounced politely while loading, e.g. "Loading invoices". Off by default. | – |
childrenRequired | ReactNode | – |
Other props pass through: extends HTMLAttributes<HTMLDivElement>. | ||
MotionSkeleton out, content in, delay… · reduced motion
Motion
| Moment | Tokens | ||
|---|---|---|---|
| Skeleton outfades where it stands, out of the layout | duration.fastease.standard140ms · cubic-bezier(0.2, 0, 0, 1) | 140mscubic-bezier(0.2, 0, 0, 1) | |
| Content infade, 4px rise and a 4px blur, one step late | duration.baseease.enter220ms · cubic-bezier(0.16, 1, 0.3, 1) | 220mscubic-bezier(0.16, 1, 0.3, 1) | |
| Delaybefore the incoming side starts | stagger.base40ms step | 40ms step | |
| Heightfrom the old height to the new one | duration.baseease.standard220ms · cubic-bezier(0.2, 0, 0, 1) | 220mscubic-bezier(0.2, 0, 0, 1) |
Reduced motion
The content replaces the skeleton in one frame, and the box takes its new height at once.
The incoming side waits one stagger step, so the two layers barely double-expose. The content ends on filter: none, not blur(0px): a leftover filter would make it the containing block for any fixed-position element inside.
Reduced motion
The swap is instant in both directions and the height changes in the same frame. The busy state and the announcement are unchanged. Stop your skeleton's own shimmer or pulse too. @youj/react's Skeleton does this for you.
AccessibilityARIA, focus, announcements
Accessibility
- While loading, the region is
aria-busy, and the skeleton isaria-hidden, so assistive technology waits for the content instead of reading placeholder shapes. loadingLabelrenders a visually hiddenrole="status"that's mounted from the start and announces the label while loading. Give it the name of what's loading, like "Loading invoices".- The outgoing side is inert while it fades, so it can't take focus or clicks on its way out.