COMPONENT / stepped-wipe-reveal
Stepped Wipe Reveal_
Block line that wipes in left to right on 12 hard steps the first time it scrolls into view, with an optional stagger delay. Requires @cybr/tokens and @cybr/effects.
Install
1. Map the
@cybrnamespace, once per project. Add this inside yourcomponents.json, or into its existing"registries"object. Nocomponents.jsonyet? Runnpx shadcn@latest initfirst to create one."registries": { "@cybr": "https://cybr-ui.com/r/{name}.json" }2. Add components:
$ npx shadcn@latest add @cybr/stepped-wipe-reveal
If shadcn reports Unknown registry "@cybr", step 1 is missing from that project's components.json.
Preview
Raw.Loud.Lit.
"use client";
import { useEffect, useRef, useState, type ComponentProps } from "react";
import { useReducedMotion } from "@/lib/use-reduced-motion";
import { cn } from "@/lib/utils";
type Phase = "static" | "waiting" | "revealing";
function getPhase(reduced: boolean, seen: boolean): Phase {
if (reduced) return "static";
return seen ? "revealing" : "waiting";
}
// Block line that wipes in left to right on 12 hard steps the first time it
// scrolls into view. Static-first: the server, no-JS and reduced-motion render
// is fully visible; it only clips once motion is confirmed on the client.
export function SteppedWipeReveal({
delay = 0,
className,
style,
...props
}: ComponentProps<"span"> & { delay?: number }) {
const ref = useRef<HTMLSpanElement>(null);
const reduced = useReducedMotion();
const [seen, setSeen] = useState(false);
useEffect(() => {
const node = ref.current;
if (reduced || seen || !node) return;
const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
setSeen(true);
observer.disconnect();
}
});
observer.observe(node);
return () => observer.disconnect();
}, [reduced, seen]);
const phase = getPhase(reduced, seen);
return (
<span
ref={ref}
className={cn(
"block",
phase === "waiting" && "[clip-path:inset(0_100%_0_0)]",
phase === "revealing" && "wipe-reveal",
className,
)}
style={phase === "revealing" ? { animationDelay: `${delay}ms`, ...style } : style}
{...props}
/>
);
}
Repo source. The shadcn CLI rewrites import aliases to match your project.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| delay | number | 0 | Milliseconds to wait, after it comes into view, before the wipe starts. Use 300ms steps to stagger lines. |
| children | ReactNode | — | The content to reveal. It is always in the DOM. |
| className | string | — | Extra classes, merged after the defaults with cn(). |
| ...props | ComponentProps<"span"> | — | Every other native <span> attribute and ref is forwarded. |
Usage
Needs both stylesheets from @cybr/tokens and @cybr/effects, and @cybr/use-reduced-motion arrives with them. After the install, import them in app/globals.css:
@import "tailwindcss";
@import "../styles/cybr-tokens.css";
@import "../styles/cybr-effects.css";
import { SteppedWipeReveal } from "@/components/ui/stepped-wipe-reveal";
export function Slogan() {
return (
<h2 className="font-display text-5xl uppercase">
<SteppedWipeReveal>Raw.</SteppedWipeReveal>
<SteppedWipeReveal delay={300}>Loud.</SteppedWipeReveal>
</h2>
);
}
Behaviour
- Trigger: wipes in once, the first time it scrolls into view.
- Timing: a left-to-right
clip-pathwipe in 12 hard steps over 1080ms, afterdelay. - Waiting: clipped away until it is in view and its delay has passed.
- Finished: fully visible, and stays that way.
- Static: Before hydration, without JavaScript and under
prefers-reduced-motion, it renders the content fully visible. On the client a line already in view can show for one frame before it clips and wipes. - Layout: renders a block
span, so each reveal is its own line. Typography comes from the parent orclassName.
Accessibility
- The content is always in the DOM and only visually clipped, so screen readers read it straight away, without waiting for the wipe.
- The wipe runs once in 12 steps and never repeats, so it does not flash (WCAG 2.3.1).
- Under
prefers-reduced-motionnothing is clipped and no wipe runs. - It inherits colour.
--signaltext needs--bgor a large size on--surface, as in the preview's “Lit.”.