Skip to content

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/motion

import { ContentSwap } from '@youj/motion'

For 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.

Reference

Propsloading, fallback, children… (4)

Props

ContentSwap props
PropType
loadingRequiredboolean

Show the fallback instead of the content.

–
fallbackRequiredReactNode

Stands in while loading: usually a skeleton shaped like the content. Hidden from assistive tech.

–
loadingLabelstring

Announced politely while loading, e.g. "Loading invoices". Off by default.

–
childrenRequiredReactNode–
Other props pass through: extends HTMLAttributes<HTMLDivElement>.
MotionSkeleton out, content in, delay… · reduced motion

Motion

Motion tokens
MomentTokens
Skeleton outfades where it stands, out of the layout
duration.fastease.standard

140ms · 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.enter

220ms · cubic-bezier(0.16, 1, 0.3, 1)

220mscubic-bezier(0.16, 1, 0.3, 1)
Delaybefore the incoming side starts
stagger.base

40ms step

40ms step
Heightfrom the old height to the new one
duration.baseease.standard

220ms · 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 is aria-hidden, so assistive technology waits for the content instead of reading placeholder shapes.
  • loadingLabel renders a visually hidden role="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.