Splash Dots
Scattered dots fly to the logo's outline and it fills in.
Preview
"use client";
import type { CSSProperties } from "react";
import { useDemo } from "../demo-context";
import { SplashDots } from "./SplashDots";
export default function Demo() {
const { logo, name, play } = useDemo();
return (
<div style={{ position: "absolute", inset: 0, "--fluid-splash-height": "100%" } as CSSProperties}>
<SplashDots logo={logo} name={name} play={play} skippable={false} />
</div>
);
}Installation
npx @usefluid/cli add splash-dotspnpm dlx @usefluid/cli add splash-dotsyarn dlx @usefluid/cli add splash-dotsbunx @usefluid/cli add splash-dotsInstall the engine.
Terminal npm i @usefluid/motionCopy these files into your project.
components/fluid/splash-dots/SplashDots.tsx "use client"; import { sampleContours, springCss } from "@usefluid/motion"; import { useSequence, useSkip } from "@usefluid/motion/react"; import { useEffect, useLayoutEffect, useMemo, useRef } from "react"; import { TextReveal } from "../text-reveal/TextReveal"; import styles from "./SplashDots.module.css"; const TIMES = [0, 0.08, 1.7, 2.1]; const fly = springCss({ w: 11, z: 0.7 }); function seeded(seed: number) { return () => { seed = (seed * 1664525 + 1013904223) % 4294967296; return seed / 4294967296; }; } type Props = { logo: string; name: string; as?: "h1" | "h2" | "p"; play?: boolean; finished?: boolean; skippable?: boolean; onDone?(): void; }; export function SplashDots({ logo, name, as: Name = "p", play = true, finished = false, skippable = true, onDone }: Props) { const last = TIMES.length - 1; const skipped = useSkip(play && skippable, finished); const live = useSequence(TIMES, play && !skipped); const step = skipped ? last : live; const done = useRef(onDone); useLayoutEffect(() => { done.current = onDone; }); useEffect(() => { if (step === last) done.current?.(); }, [step, last]); const dots = useMemo(() => { const random = seeded(7); return sampleContours(logo, 40).flatMap((c) => Array.from(c.x, (x, i) => ({ x, y: c.y[i], sx: random() * 180 - 30, sy: random() * 180 - 30, delay: random() * 380 })), ); }, [logo]); return ( <div className={styles.root} data-step={step}> <svg viewBox="-10 -10 140 140" className={styles.logo} aria-hidden> <path d={logo} fillRule="evenodd" className={styles.fill} /> {dots.map((dot, i) => ( <circle key={i} r={1.7} className={styles.dot} style={{ transform: step === 0 ? `translate(${dot.sx}px, ${dot.sy}px)` : `translate(${dot.x}px, ${dot.y}px)`, transitionProperty: "transform, opacity", transitionDuration: `${fly.duration}ms, 300ms`, transitionDelay: `${dot.delay}ms, 0ms`, transitionTimingFunction: `${fly.easing}, ease`, }} /> ))} </svg> <Name className={styles.name}> {skipped ? name : <TextReveal text={name} play={step >= last} stagger={36} />} </Name> </div> ); }components/fluid/splash-dots/SplashDots.module.css .root { container-type: size; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: clamp(8px, 4cqh, 20px); width: 100%; height: 100%; min-height: var(--fluid-splash-height, 100svh); background: transparent; color: var(--fluid-accent); } .logo { width: clamp(80px, 38cqmin, 170px); aspect-ratio: 1; overflow: visible; } .dot { fill: currentColor; } .root[data-step="0"] .dot { opacity: 0; } .root[data-step="2"] .dot, .root[data-step="3"] .dot { opacity: 0; } .fill { fill: currentColor; opacity: 0; } .root[data-step="2"] .fill, .root[data-step="3"] .fill { opacity: 1; transition: opacity 380ms ease; } .name { margin: 0; min-height: 1.1em; color: var(--fluid-text); font: 600 clamp(26px, 11cqmin, 60px) / 1.1 var(--fluid-font); letter-spacing: -0.04em; } @media (prefers-reduced-motion: reduce) { .dot { transition: none !important; } }It uses these pieces too, so copy them alongside:
components/fluid/text-reveal/TextReveal.tsx "use client"; import { springCss, springs } from "@usefluid/motion"; import { useEffect, useState } from "react"; import styles from "./TextReveal.module.css"; const spring = springCss(springs.bouncy); export function TextReveal({ text, play = true, stagger = 28 }: { text: string; play?: boolean; stagger?: number }) { const [shown, setShown] = useState(false); useEffect(() => { setShown(false); if (!play) return; let second = 0; const first = requestAnimationFrame(() => { second = requestAnimationFrame(() => setShown(true)); }); return () => { cancelAnimationFrame(first); cancelAnimationFrame(second); }; }, [play, text]); let index = 0; return ( <span className={styles.root} data-play={shown} style={{ "--easing": spring.easing, "--duration": `${spring.duration}ms` } as React.CSSProperties} > <span className={styles.srOnly}>{text}</span> <span aria-hidden> {text.split(" ").map((word, w) => ( <span key={w} className={styles.word}> {word.split("").map((char, c) => ( <span key={c} className={styles.char} style={{ transitionDelay: shown ? `${index++ * stagger}ms` : "0ms" }}> {char} </span> ))} {" "} </span> ))} </span> </span> ); }components/fluid/text-reveal/TextReveal.module.css .root { display: inline-block; } .srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); } .word { display: inline-block; white-space: pre; } .char { display: inline-block; opacity: 0; transform: translateY(0.5em) scale(0.8); transition-property: opacity, transform; transition-duration: 180ms, var(--duration); transition-timing-function: ease, cubic-bezier(0.34, 1.56, 0.64, 1); } @supports (transition-timing-function: linear(0, 1)) { .char { transition-timing-function: ease, var(--easing); } } .root[data-play="true"] .char { opacity: 1; transform: none; } @media (prefers-reduced-motion: reduce) { .char { opacity: 1; transform: none; transition: none; } }Import the theme once, then point its variables at your own tokens.
layout.tsx import "@usefluid/motion/theme.css";
Usage
import { SplashDots } from "./SplashDots";
<SplashDots logo={logo} name="Acme" as="h1" finished={seen} onDone={remember} />API reference
| Prop | Type | Default | Description |
|---|---|---|---|
logo | string | — | Your logo path. |
name | string | — | |
as | "h1" | "h2" | "p" | "p" | |
finished | boolean | false | |
skippable | boolean | true | |
onDone | () => void | — | |
play | boolean | true |
Keyboard and screen readers
The name is read as text. Reduced motion shows the final screen.
Notes
It fills the screen by default; set --fluid-splash-height to fit it in a box.
Every colour comes from the --fluid-* variables, and the piece is yours to change.
How it is made
These are the same points the engine uses for the morph, now visible. Each one starts at a place picked with a fixed seed, so the scene is always the same, and flies to the outline on a spring curve with a small delay. Then the logo fills and the name comes in.
For your agent
For your agent
Add Splash Dots to my project with the Fluid library. Build my product's opening with SplashDots, my logo and the brand name. 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.