"use client"
import * as React from "react"
import { PauseIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { useReducedMotion } from "@/hooks/use-reduced-motion"
import { Toggle } from "@/components/ui/toggle"
/**
* Where the stage is: `running`; `paused` — allowed to move but off screen, in
* a background tab or held by the reader; or `still` — the reader asked for
* less motion, so nothing on it will move.
*/
export type MotionPlayback = "running" | "paused" | "still"
/**
* A stylesheet of keyframes for what moves on the stage, hoisted into the
* document head once per `href`. Under a `style-src` policy without
* `'unsafe-inline'`, give React the style nonce as a render option — it puts
* it on every stylesheet it hoists; without it the sheet is blocked, and every
* picture simply shows its final frame.
*/
export type MotionKeyframes = {
/** Names the stylesheet: two stages that pass the same `href` share one copy of it. */
href: string
css: string
}
export type MotionStageProps = Omit<React.ComponentProps<"div">, "children"> & {
children: React.ReactNode
/** The toggle's name, the same pressed or not: pressed means held still. */
label?: string
keyframes?: MotionKeyframes
}
function subscribeVisibility(onChange: () => void) {
document.addEventListener("visibilitychange", onChange)
return () => document.removeEventListener("visibilitychange", onChange)
}
const pageVisible = () => document.visibilityState !== "hidden"
const visibleOnServer = () => true
/**
* A stage for looping illustrations, and the toggle that holds them still.
*
* It runs only while three things hold: the reader has not asked for less
* motion — by the OS setting, or a `[data-motion="reduced"]` above it — it is
* on screen, and the tab is in front. `data-playback` says which, and what
* moves on it keys its animations to a running stage, e.g.
* `motion-safe:in-data-[playback=running]:animate-[spin_4s_linear_infinite]`,
* so anywhere else they are removed, not merely paused, and cost nothing. The
* stage also stops every animation inside it in CSS under either signal, so
* nothing moves before the first effect has read them.
*
* The toggle is the kit's Toggle, a real button named "Pause motion" whether
* or not it is pressed — `aria-pressed` says which — and pressed, it holds
* everything still until pressed again (WCAG 2.2.2). Under reduced motion it
* is gone, since there is nothing to hold.
*/
function MotionStage({ className, children, label = "Pause motion", keyframes, ...props }: MotionStageProps) {
const stage = React.useRef<HTMLDivElement>(null)
const reduced = useReducedMotion(stage)
const visible = React.useSyncExternalStore(subscribeVisibility, pageVisible, visibleOnServer)
const [inView, setInView] = React.useState(false)
const [held, setHeld] = React.useState(false)
React.useEffect(() => {
const node = stage.current
if (!node || reduced || typeof IntersectionObserver !== "function") return
// A report from an observer this effect has let go of is dropped: the
// stage may have gone still in between.
let live = true
const observer = new IntersectionObserver((entries) => {
if (live) setInView(entries.some((entry) => entry.isIntersecting))
})
observer.observe(node)
return () => {
live = false
observer.disconnect()
}
}, [reduced])
const playback: MotionPlayback = reduced ? "still" : held || !inView || !visible ? "paused" : "running"
return (
<div
ref={stage}
data-slot="motion-stage"
data-playback={playback}
className={cn(
"flex flex-col gap-3 motion-reduce:**:animate-none! in-data-[motion=reduced]:**:animate-none!",
className
)}
{...props}
>
{keyframes ? (
<style href={keyframes.href} precedence="default">
{keyframes.css}
</style>
) : null}
{reduced ? null : (
<div data-slot="motion-stage-controls" className="flex justify-end motion-reduce:hidden in-data-[motion=reduced]:hidden">
<Toggle size="sm" pressed={held} onPressedChange={setHeld}>
<PauseIcon aria-hidden="true" data-icon="inline-start" />
{label}
</Toggle>
</div>
)}
{children}
</div>
)
}
export { MotionStage }