Motion's useCurtains hook creates cover-then-reveal page and element transitions in React. It covers the view with an effect, runs a state change or navigation while it's hidden, then reveals the new view: drop the curtain, change the set, raise the curtain.
import { useCurtains } from "motion-plus/react"
import { wipe } from "motion-plus/curtains"
const [curtains, isPending] = useCurtains()
await curtains(() => router.push("/about"), {
effect: wipe({ direction: "left", angle: 12 }),
})
The hook returns a curtains function with the same call signature as the vanilla curtains(), plus an isPending flag. What it adds over the vanilla function is React awareness: it runs your callback inside startTransition and holds the reveal until React has committed the update, including any Suspense it triggered, and painted a frame. So the reveal waits for the new content to be ready, not just for the click.
Features
- React-aware: Runs inside a transition and holds the reveal until React has committed and painted the new view.
- Mix effects: Cover with one and reveal with another.
- Tiny: Just a couple kb on top of the mini
animate()function. - Hardware accelerated: All effects are GPU accelerated.
- Robust: No
z-indexhacks, uses the browser's Popover API to ensure full-page overlays. - Accessible: Respects a user's
prefers-reduced-motionsetting.
Install
First, add the motion-plus package to your project using your private token. You need to be a Motion+ member to generate a private token.
npm install "https://api.motion.dev/registry.tgz?package=motion-plus&version=2.12.0&token=YOUR_AUTH_TOKEN"
Usage
First, import useCurtains from motion-plus/react, and any effects from motion-plus/curtains:
import { useCurtains } from "motion-plus/react"
import { wipe } from "motion-plus/curtains"
Create a page transition
useCurtains returns a curtains function and an isPending flag:
const [curtains, isPending] = useCurtains()
Call curtains with a function that updates the view, like a state change or a route navigation. It covers the view, runs the update while it's hidden, then reveals once React has committed and painted:
const [page, setPage] = useState(0)
const next = () => curtains(() => setPage((p) => p + 1))
Configure the animation
By default, curtains will use a faded overlay:
But, it also accepts a set of options that you can use to configure the animation with effects and transition options:
curtains(update, {
transition: { duration: 0.4 },
effect: clipWipe,
})
Show a pending state
While the curtain is up and the update is in flight, isPending is true. Use it to disable controls or show progress, so repeated clicks don't stack up:
<button onClick={next} disabled={isPending}>
Next
</button>
Mix effects
By default, curtains will use the same transition and effect for both the cover and reveal animations.
Either can be set as a [cover, reveal] tuple to use different values for each.
curtains(update, {
effect: [wipe({ direction: "left" }), iris()],
transition: [{ duration: 0.3 }, { duration: 0.5 }],
})
Scope to a specific element
By passing a scope element, we can apply the overlay to just one element. Pass the element from a ref:
const cardRef = useRef(null)
const next = () =>
curtains(update, { effect: wipe(), scope: cardRef.current })
The element must establish a containing block, so give it position: relative (or another non-static position).
Sequencing
curtains() returns a promise that resolves once the reveal has finished, so you can sequence work after the transition.
await curtains(update, { effect: wipe() })
// the new view is now fully revealed
Styling the curtain
Every covering element (panel, tile, slat or door) carries the motion-curtain class. A dark default fill is applied, which you can override in CSS:
.motion-curtain {
background: #6d28d9;
}
The overlay container carries the motion-curtains class, in case you need to position or theme the containing element.
Effects
Every effect is imported separately to remain tree-shakable for lower bundlesize. Import from motion-plus/curtains:
import { iris } from "motion-plus/curtains"
Pass the factory itself to use its defaults, or call it with options:
curtains(update, { effect: iris }) // defaults
curtains(update, {
effect: iris({ origin: { x: 20, y: 80 } }),
}) // configured
fade
The default. A solid panel fades in over the view, then fades back out.
wipe
A solid panel sweeps across the view. With an angle, the leading edge is slanted.
curtains(update, { effect: wipe({ direction: "left", angle: 12 }) })
directionaccepts"up","down","left"or"right"(or a[cover, reveal]tuple to set each phase).directionModeis"normal"to continue past on the reveal, or"reverse"to retreat the way the curtain came.angletilts the leading edge, in degrees.
clipWipe
A wipe whose edge is a clip on a static panel, so the overlay itself never moves. This makes it ideal for revealing fixed content like an image, gradient or poster. The edge can be slanted with angle, or bowed into a curve with bow.
-
directionaccepts"up","down","left"or"right"(or a[cover, reveal]tuple to set each phase). -
directionModeis"normal"to continue past on the reveal, or"reverse"to retreat the way the curtain came. -
angleslants the edge, in degrees. -
bowbows the leading edge into a convex curve, as a fraction of the box (0 to around 0.4). Takes precedence overangle.
iris
A circle that expands from a point to cover, then contracts to reveal.
originis{ x, y }, where numbers are 0 to 1 fractions of the box (like Motion'soriginX/originY), or CSS-length strings such as"100px". Defaults to the centre{ x: 0.5, y: 0.5 }.
// grow from the pointer
curtains(update, {
effect: iris({
origin: {
x: clientX / innerWidth,
y: clientY / innerHeight,
},
}),
})
Feed the pointer position to origin to grow the iris from wherever the user clicked:
doors
Two panels meet in the middle to cover, then part to reveal.
directionchooses the axis. Horizontal values give left and right doors, vertical values give top and bottom doors. Defaults to horizontal.
pixels
A grid of tiles fills in to cover, then clears out to reveal. By default they fill in a random order; set a direction to sweep them in as a wavefront, and noise to soften that sweep's edge.
-
sizeis the tile edge: px (100), a"50%"fraction of the box, or[width, height]for rectangular tiles. Defaults to100. -
directionsweeps the fill as a wavefront, angled in degrees likewipe(0= right,90= down,180= left,270= up). Tiles on the same wavefront flip together, so the edge reads as a hard line. Takes precedence overorder. -
noisesoftens adirectionsweep's edge,0to1:0keeps a hard, straight line; higher values dither the edge into a soft band;1is fully random. -
orderis the activation sequence when nodirectionis set:"rows","columns","diagonal","radial"or"random". Defaults to"random". -
directionModeof"reverse"plays the sweep or order backwards on the reveal.
blinds
Venetian-blind slats scale shut to cover, then open to reveal.
-
sizeis the slat thickness in px. Defaults to64. -
directionof"row"gives horizontal slats,"column"gives vertical slats. Defaults to"row". -
directionModeis"normal"to continue past on the reveal, or"reverse"to retreat the way the curtain came.
shutter
Interleaved columns slide in from alternating top and bottom.
sizeis the column width in px. Defaults to120.
staggerWipe
Strips that each wipe in one direction, staggered across the perpendicular axis, for a cascading wipe. By default, full-height columns wipe downward, staggered left to right.
-
sizeis the strip thickness in px. Defaults to120. -
directionis the way the wipe travels ("up","down","left"or"right"). Defaults to"down". -
directionModeis"normal"to continue past on the reveal, or"reverse"to retreat the way the curtain came.
Custom effects
An effect is just a factory that returns three methods, so you can build your own to cover and reveal however you like.
-
setup(container, box)builds the overlay's DOM insidecontainerand stashes whatevercoverandrevealwill animate.boxis the container's measured{ width, height }. Size your effect frombox, notwindow, so it still works whenscoped to an element. Add themotion-curtainclass to your elements to inherit the default fill and the styling hook. -
cover(transition)animates those elements in until they fully hide the page. Return the animation: Motion'sanimate()already satisfies the contract (afinishedpromise and astop()method).transitionis the one from the call site, orundefined. -
reveal(transition)animates them back out to expose the new view.
Here's a panel that slides up to cover, then continues up to reveal:
import { animate } from "motion/mini"
function slideUp() {
let panel
return {
setup(container) {
panel = document.createElement("div")
panel.className = "motion-curtain"
Object.assign(panel.style, {
position: "absolute",
inset: "0",
transform: "translateY(100%)",
})
container.appendChild(panel)
},
cover(transition) {
return animate(
panel,
{ transform: ["translateY(100%)", "translateY(0%)"] },
transition,
)
},
reveal(transition) {
return animate(
panel,
{ transform: ["translateY(0%)", "translateY(-100%)"] },
transition,
)
},
}
}
Then pass it like any built-in effect:
curtains(update, { effect: slideUp() })
To stagger many elements, like the tile effects do, build them all in setup, store them in the closure, then pass a delay: stagger() in your animate call.
Options
effect
The transition effect to use.
import { useCurtains } from "motion-plus/react"
import { iris } from "motion-plus/curtains"
curtains(update, { effect: iris })
transition
The animation timing to use. A single transition times both phases; a [cover, reveal] tuple times each independently.
curtains(update, {
transition: [{ duration: 0.3 }, { duration: 0.6 }],
})
This accepts delay: stagger() for effects like pixels, which animate many elements.
scope
By default the curtain covers the whole viewport, promoted to the top layer via the Popover API so it sits above all content without trapping focus. Pass an element to scope to mount the curtain inside it instead, so it covers and tracks just that element and follows it on scroll.
The element must establish a containing block, so give it position: relative (or another non-static position).
const cardRef = useRef(null)
curtains(update, { effect: wipe(), scope: cardRef.current })
useCurtains vs AnimateView
Motion ships two ways to transition between views in React, and they work in opposite directions.
AnimateView is built on React's ViewTransition and the browser's View Transition API. You wrap elements in it, and it animates between their before and after states, so they morph size and position, and elements with a matching name perform shared-element animations. The content itself animates.
useCurtains never animates your content. It mounts an opaque overlay, the curtain, runs your update while the view is completely hidden, then clears the overlay. Only the curtain moves.