Splash Shapes
Shapes become the logo, it rises, and the sentence and the button appear.
Preview
"use client";
import type { CSSProperties } from "react";
import { useDemo } from "../demo-context";
import { SplashShapes } from "./SplashShapes";
export default function Demo() {
const { logo, play } = useDemo();
return (
<div style={{ position: "absolute", inset: 0, "--fluid-splash-height": "100%" } as CSSProperties}>
<SplashShapes
logo={logo}
title="From idea,"
words={["to brand.", "to site.", "to video.", "to product."]}
action="Get started"
play={play}
skippable={false}
/>
</div>
);
}Installation
npx @usefluid/cli add splash-shapespnpm dlx @usefluid/cli add splash-shapesyarn dlx @usefluid/cli add splash-shapesbunx @usefluid/cli add splash-shapesInstall the engine.
Terminal npm i @usefluid/motionCopy these files into your project.
components/fluid/splash-shapes/SplashShapes.tsx "use client"; import { shapes, springCss, springs } from "@usefluid/motion"; import { MorphPath, useSequence, useSkip } from "@usefluid/motion/react"; import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react"; import { SpringButton } from "../spring-button/SpringButton"; import { WordCycle } from "../word-cycle/WordCycle"; import styles from "./SplashShapes.module.css"; const DEFAULT_ROUTE = [shapes.dot, shapes.circle, shapes.triangle, shapes.flower, shapes.square, shapes.drop, shapes.capsule]; const BEAT = 0.36; const rise = springCss(springs.soft); type Props = { logo: string; title: string; words: string[]; action?: string; onAction?(): void; children?: ReactNode; route?: string[]; as?: "h1" | "h2"; play?: boolean; finished?: boolean; skippable?: boolean; onDone?(): void; }; export function SplashShapes({ logo, title, words, action, onAction, children, route = DEFAULT_ROUTE, as: Heading = "h2", play = true, finished = false, skippable = true, onDone, }: Props) { const logoAt = route.length * BEAT; const times = [...route.map((_, i) => i * BEAT), logoAt, logoAt + 0.75, logoAt + 1.5]; const stepLogo = route.length; 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; }); const column = useRef<HTMLDivElement>(null); const slot = useRef<HTMLDivElement>(null); const [offset, setOffset] = useState(0); const [placed, setPlaced] = useState(false); useLayoutEffect(() => { const measure = () => { if (!column.current || !slot.current) return; setOffset(column.current.offsetHeight / 2 - (slot.current.offsetTop + slot.current.offsetHeight / 2)); }; measure(); let frame = requestAnimationFrame(() => { frame = requestAnimationFrame(() => setPlaced(true)); }); const observer = new ResizeObserver(measure); if (column.current) observer.observe(column.current); return () => { cancelAnimationFrame(frame); observer.disconnect(); }; }, []); useEffect(() => { if (step === last) done.current?.(); }, [step, last]); const d = step >= stepLogo ? logo : route[step]; const stage = step >= last ? "action" : step >= stepLogo + 1 ? "rise" : "shapes"; return ( <div className={styles.root} data-stage={stage}> <div ref={column} className={styles.column}> <div ref={slot} className={styles.slot} style={{ transform: stage === "shapes" ? `translateY(${offset}px) scale(1.35)` : "none", ...(placed ? rise.transition("transform") : { transitionProperty: "none" }), }} > <svg viewBox="-8 -8 136 136" aria-hidden> <MorphPath d={d} fill="currentColor" config={{ w: 14, z: 0.74 }} /> </svg> </div> <Heading className={styles.title}> <span className={styles.titleLine}>{title}</span>{" "} <span className={styles.words}> <WordCycle key={stage === "shapes" ? "wait" : skipped ? "end" : "go"} words={skipped ? words.slice(-1) : words} every={0.5} loop={false} play={stage !== "shapes" && !skipped} /> </span> </Heading> <div className={styles.action} inert={stage !== "action"}> {children ?? (action ? <SpringButton onClick={onAction}>{action}</SpringButton> : null)} </div> </div> </div> ); }components/fluid/splash-shapes/SplashShapes.module.css .root { container-type: size; display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; min-height: var(--fluid-splash-height, 100svh); overflow: hidden; background: transparent; color: var(--fluid-text); font-family: var(--fluid-font); } .column { box-sizing: border-box; position: relative; display: flex; flex-direction: column; align-items: center; width: min(100%, 460px); padding: 16px; text-align: center; } .slot { width: clamp(64px, 24cqmin, 110px); aspect-ratio: 1; margin-bottom: clamp(8px, 4cqh, 20px); color: var(--fluid-accent); } .title { margin: 0; font-size: clamp(24px, 9cqmin, 54px); line-height: 1.04; font-weight: 600; letter-spacing: -0.04em; transition: opacity 420ms ease, filter 420ms ease, transform 420ms cubic-bezier(0.22, 1, 0.36, 1); } .titleLine, .words { display: block; } .words { color: var(--fluid-accent); } .action { margin-top: clamp(10px, 5cqh, 24px); transition: opacity 420ms ease, filter 420ms ease, transform 420ms cubic-bezier(0.22, 1, 0.36, 1); } .root[data-stage="shapes"] .title, .root[data-stage="shapes"] .action, .root[data-stage="rise"] .action { opacity: 0; filter: blur(8px); transform: translateY(10px); } .root[data-stage="shapes"] .action, .root[data-stage="rise"] .action { pointer-events: none; } @media (prefers-reduced-motion: reduce) { .title, .action { transition: none; } }It uses these pieces too, so copy them alongside:
components/fluid/spring-button/SpringButton.tsx "use client"; import { springs } from "@usefluid/motion"; import { useSpring } from "@usefluid/motion/react"; import { type ButtonHTMLAttributes, useRef, useState } from "react"; import styles from "./SpringButton.module.css"; type Props = ButtonHTMLAttributes<HTMLButtonElement> & { variant?: "solid" | "quiet"; pressed?: boolean; }; export function SpringButton({ variant = "solid", pressed, className, ...props }: Props) { const el = useRef<HTMLButtonElement>(null); const [hover, setHover] = useState(false); const [down, setDown] = useState(false); const isDown = pressed ?? down; useSpring( { s: isDown ? 0.93 : hover ? 1.025 : 1 }, ({ s }, velocity) => { if (!el.current) return; const stretch = Math.max(-0.05, Math.min(0.05, velocity.s * 0.03)); el.current.style.transform = `scale(${s - stretch}, ${s + stretch})`; }, springs.bouncy, ); return ( <button {...props} ref={el} className={[styles.button, className].filter(Boolean).join(" ")} data-variant={variant} onPointerEnter={(e) => { if (e.pointerType === "mouse") setHover(true); props.onPointerEnter?.(e); }} onPointerLeave={(e) => { setHover(false); setDown(false); props.onPointerLeave?.(e); }} onPointerDown={(e) => { setDown(true); props.onPointerDown?.(e); }} onPointerUp={(e) => { setDown(false); props.onPointerUp?.(e); }} onPointerCancel={(e) => { setDown(false); props.onPointerCancel?.(e); }} onKeyDown={(e) => { if (e.key === " " || e.key === "Enter") setDown(true); props.onKeyDown?.(e); }} onKeyUp={(e) => { setDown(false); props.onKeyUp?.(e); }} onBlur={(e) => { setDown(false); props.onBlur?.(e); }} /> ); }components/fluid/spring-button/SpringButton.module.css .button { min-height: 48px; padding: 0 22px; border: 0; border-radius: 999px; font: 500 15px/1 var(--fluid-font); letter-spacing: -0.01em; cursor: pointer; user-select: none; touch-action: manipulation; will-change: transform; transition: background-color 200ms ease; } .button[data-variant="solid"] { background: var(--fluid-accent); color: var(--fluid-on-accent); } .button[data-variant="quiet"] { background: color-mix(in srgb, var(--fluid-text) 10%, transparent); color: var(--fluid-text); } @media (hover: hover) { .button[data-variant="quiet"]:hover { background: color-mix(in srgb, var(--fluid-text) 16%, transparent); } } .button:focus-visible { outline: 2px solid var(--fluid-focus); outline-offset: 3px; } .button:disabled { opacity: 0.45; cursor: not-allowed; }components/fluid/word-cycle/WordCycle.tsx "use client"; import { useReducedMotion } from "@usefluid/motion/react"; import { useEffect, useState } from "react"; import styles from "./WordCycle.module.css"; type Props = { words: string[]; every?: number; loop?: boolean; play?: boolean; }; export function WordCycle({ words, every = 1.6, loop = true, play = true }: Props) { const reduced = useReducedMotion(); const [index, setIndex] = useState(0); const [leaving, setLeaving] = useState<number | null>(null); const last = words.length - 1; const finished = !loop && index >= last; useEffect(() => { if (!play || reduced || finished || words.length < 2) return; const timer = setInterval(() => { setIndex((i) => { if (!loop && i >= last) return i; setLeaving(i); return i + 1 <= last ? i + 1 : 0; }); }, every * 1000); return () => clearInterval(timer); }, [play, reduced, finished, every, loop, last, words.length]); const current = reduced ? last : index; const out = reduced ? null : leaving; return ( <span className={styles.root}> <span className={styles.srOnly}>{words[last]}</span> <span className={styles.stack} aria-hidden> {words.map((word) => ( <span key={word} className={styles.sizer}>{word}</span> ))} <span key={`in-${current}`} className={out === null ? styles.still : styles.in} onAnimationEnd={() => setLeaving(null)} > {words[current]} </span> {out !== null && <span key={`out-${out}`} className={styles.out}>{words[out]}</span>} </span> </span> ); }components/fluid/word-cycle/WordCycle.module.css .root { display: inline-block; } .srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } .stack { display: inline-grid; } .stack > * { grid-area: 1 / 1; white-space: nowrap; } .sizer { visibility: hidden; } .still { display: block; } .in { display: block; animation: in 340ms cubic-bezier(0.22, 1, 0.36, 1) both; } .out { display: block; pointer-events: none; animation: out 200ms cubic-bezier(0.22, 1, 0.36, 1) both; } @keyframes in { from { opacity: 0; filter: blur(7px); transform: translateY(0.3em); } to { opacity: 1; filter: blur(0); transform: none; } } @keyframes out { from { opacity: 1; filter: blur(0); transform: none; } to { opacity: 0; filter: blur(7px); transform: translateY(-0.3em); } }Import the theme once, then point its variables at your own tokens.
layout.tsx import "@usefluid/motion/theme.css";
Usage
import { SplashShapes } from "./SplashShapes";
<SplashShapes logo={logo} as="h1" title="Coffee," words={["roasted.", "today."]} finished={seen} onDone={remember}>
<p>Order ahead and pick up on your way.</p>
<a href="/order">Order now</a>
</SplashShapes>API reference
| Prop | Type | Default | Description |
|---|---|---|---|
logo | string | — | Your logo path. |
title | string | — | The fixed part of the headline. |
words | string[] | — | The words that cycle once and stop on the last. |
children | ReactNode | — | Your real content, shown at the end. |
action, onAction | — | A ready button instead of children. | |
as | "h1" | "h2" | "h2" | |
route | string[] | — | |
finished | boolean | false | |
skippable | boolean | true | |
onDone | () => void | — | |
play | boolean | true |
Keyboard and screen readers
The content is inert until visible. 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
A timeline marks each shape, the logo's arrival and the rise. The logo starts at the centre of the screen and rises to its place on a spring transition. The sentence and the button come out of a blur, and the button can only be clicked once it shows.
For your agent
For your agent
Add Splash Shapes to my project with the Fluid library. Build my product's opening with SplashShapes, my logo and a sentence about what I do. 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.