Logo Wipe
A screen change where your logo covers, holds and leaves upwards.
Preview
"use client";
import { useState } from "react";
import { useAutoplay, useDemo } from "../demo-context";
import { SegmentedPicker } from "../segmented-picker/SegmentedPicker";
import { LogoWipe } from "./LogoWipe";
const screens: Record<string, [string, string]> = {
a: ["Home", "Everything that matters today."],
b: ["Orders", "12 orders waiting for a reply."],
c: ["Customers", "3 new customers this week."],
};
const keys = Object.keys(screens);
export default function Demo() {
const { logo } = useDemo();
const [view, setView] = useState("a");
useAutoplay(() => setView((v) => keys[(keys.indexOf(v) + 1) % keys.length]), 2000);
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 12, width: "100%", maxWidth: 320 }}>
<div style={{ width: "100%", height: 130, borderRadius: "var(--fluid-radius)", overflow: "hidden", border: "1px solid var(--fluid-border)", background: "var(--fluid-surface)" }}>
<LogoWipe logo={logo} view={view}>
<div style={{ height: 130, padding: 18, fontFamily: "var(--fluid-font)", color: "var(--fluid-text)" }}>
<p style={{ margin: 0, fontSize: 24, fontWeight: 600, letterSpacing: "-0.03em" }}>{screens[view][0]}</p>
<p style={{ margin: "6px 0 0", fontSize: 14, color: "var(--fluid-text-2)" }}>{screens[view][1]}</p>
</div>
</LogoWipe>
</div>
<SegmentedPicker
label="Screen"
value={view}
onChange={setView}
options={Object.entries(screens).map(([value, [label]]) => ({ value, label }))}
/>
</div>
);
}Installation
npx @usefluid/cli add logo-wipepnpm dlx @usefluid/cli add logo-wipeyarn dlx @usefluid/cli add logo-wipebunx @usefluid/cli add logo-wipeInstall the engine.
Terminal npm i @usefluid/motionCopy these files into your project.
components/fluid/logo-wipe/LogoWipe.tsx "use client"; import { useReducedMotion } from "@usefluid/motion/react"; import { type ReactNode, useCallback, useEffect, useRef, useState } from "react"; import styles from "./LogoWipe.module.css"; const COVER_MS = 420; const HOLD_MS = 240; const LEAVE_MS = 460; type Phase = "idle" | "cover" | "leave"; function Curtain({ logo, phase, fixed }: { logo: string; phase: Phase; fixed?: boolean }) { if (phase === "idle") return null; return ( <div className={styles.curtain} data-phase={phase} data-fixed={fixed || undefined} aria-hidden> <svg viewBox="-8 -8 136 136" className={styles.logo}> <path d={logo} fillRule="evenodd" fill="currentColor" /> </svg> </div> ); } type Props = { logo: string; view: string; children: ReactNode; fixed?: boolean; }; export function LogoWipe({ logo, view, children, fixed }: Props) { const reduced = useReducedMotion(); const [shown, setShown] = useState(view); const [phase, setPhase] = useState<Phase>("idle"); const held = useRef(children); if (view === shown) held.current = children; useEffect(() => { if (view === shown) return; if (reduced) { setShown(view); return; } setPhase("cover"); const swap = setTimeout(() => setShown(view), COVER_MS); const leave = setTimeout(() => setPhase("leave"), COVER_MS + HOLD_MS); const done = setTimeout(() => setPhase("idle"), COVER_MS + HOLD_MS + LEAVE_MS); return () => { clearTimeout(swap); clearTimeout(leave); clearTimeout(done); }; }, [view, reduced]); return ( <div className={styles.root}> {view === shown ? children : held.current} <Curtain logo={logo} phase={phase} fixed={fixed} /> </div> ); } export function useCurtain() { const reduced = useReducedMotion(); const [covered, setCovered] = useState(false); const cover = useCallback(() => { if (reduced) return Promise.resolve(); setCovered(true); return new Promise<void>((resolve) => setTimeout(resolve, COVER_MS + HOLD_MS)); }, [reduced]); const uncover = useCallback(() => setCovered(false), []); return { covered, cover, uncover }; } export function LogoCurtain({ logo, covered, fixed = true }: { logo: string; covered: boolean; fixed?: boolean }) { const [phase, setPhase] = useState<Phase>("idle"); useEffect(() => { if (covered) { setPhase("cover"); return; } setPhase((p) => (p === "idle" ? p : "leave")); const done = setTimeout(() => setPhase("idle"), LEAVE_MS); return () => clearTimeout(done); }, [covered]); return <Curtain logo={logo} phase={phase} fixed={fixed} />; }components/fluid/logo-wipe/LogoWipe.module.css .root { position: relative; overflow: hidden; border-radius: inherit; } .curtain { position: absolute; inset: 0; z-index: 2; display: grid; place-items: center; background: var(--fluid-accent); color: var(--fluid-on-accent); } .curtain[data-fixed] { position: fixed; z-index: 1000; } .curtain[data-phase="cover"] { animation: cover 420ms cubic-bezier(0.65, 0, 0.35, 1) both; } .curtain[data-phase="leave"] { animation: leave 460ms cubic-bezier(0.65, 0, 0.35, 1) both; } .logo { width: min(34%, 72px); aspect-ratio: 1; animation: pop 560ms cubic-bezier(0.34, 1.56, 0.64, 1) 120ms both; } @keyframes cover { from { clip-path: circle(0 at 50% 50%); } to { clip-path: circle(75% at 50% 50%); } } @keyframes leave { from { clip-path: inset(0 0 0 0); } to { clip-path: inset(0 0 100% 0); } } @keyframes pop { from { transform: scale(0.4); opacity: 0; } to { transform: none; opacity: 1; } } @media (prefers-reduced-motion: reduce) { .curtain, .logo { animation: none; } }Import the theme once, then point its variables at your own tokens.
layout.tsx import "@usefluid/motion/theme.css";
Usage
import { LogoWipe } from "./LogoWipe";
<LogoWipe logo={logo} view={step}>
{step === "plan" ? <Plan /> : <Checkout />}
</LogoWipe>API reference
| Prop | Type | Default | Description |
|---|---|---|---|
LogoWipe logo | string | — | Your logo path. |
LogoWipe view | string | — | Change it to play the wipe; the old children stay until covered. |
LogoWipe fixed | boolean | false | Cover the whole screen instead of the wrapped area. |
LogoCurtain covered | boolean | — | Controlled curtain, for route changes. |
useCurtain() | { covered, cover(), uncover() } | — | await cover() resolves once the screen is covered. |
Keyboard and screen readers
Hidden from screen readers. Reduced motion skips it.
Notes
LogoWipe holds the old children, which works for screens inside one component. For routes in Next.js App Router use the curtain:
const curtain = useCurtain();
const router = useRouter();
const pathname = usePathname();
useEffect(() => curtain.uncover(), [pathname]);
<a
href="/projects"
onClick={async (e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || pathname === "/projects") return;
e.preventDefault();
await curtain.cover();
router.push("/projects");
}}
>
Projects
</a>
<LogoCurtain logo={logo} covered={curtain.covered} />Every colour comes from the --fluid-* variables, and the piece is yours to change.
How it is made
When the screen changes, a curtain in the brand colour grows in a circle from the centre, with the logo popping in the middle. The content swaps under the curtain, which then rises. With reduced motion the swap is direct.
For your agent
For your agent
Add Logo Wipe to my project with the Fluid library. Use LogoWipe on the important screen changes, with my logo. Read my project first. Use my logo, my colours and my font through the --fluid-* variables. Keep 44px touch targets, visible focus and reduced motion. If I have no SVG logo, ask before making one up.