Splash Choice
Shapes become the logo, it rises, and each option you pick changes the shape and the sentence.
Preview
"use client";
import type { CSSProperties } from "react";
import { shapes } from "@usefluid/motion";
import { useState } from "react";
import { useAutoplay, useDemo } from "../demo-context";
import { type Option, SplashChoice } from "./SplashChoice";
const options: Option[] = [
{ value: "shops", label: "Shops", word: "shops.", shape: shapes.square },
{ value: "apps", label: "Apps", word: "apps.", shape: shapes.capsule },
{ value: "brands", label: "Brands", word: "brands.", shape: shapes.flower },
];
export default function Demo() {
const { logo, play } = useDemo();
const [value, setValue] = useState<string | null>(null);
const [turn, setTurn] = useState(0);
useAutoplay(() => {
setTurn((t) => t + 1);
if (turn < 4) return;
setValue(options[(turn - 4) % options.length].value);
}, 900);
return (
<div style={{ position: "absolute", inset: 0, "--fluid-splash-height": "100%" } as CSSProperties}>
<SplashChoice logo={logo} title="Made for" word="you." options={options} value={value} onChange={setValue} play={play} skippable={false} />
</div>
);
}Installation
npx @usefluid/cli add splash-choicepnpm dlx @usefluid/cli add splash-choiceyarn dlx @usefluid/cli add splash-choicebunx @usefluid/cli add splash-choiceInstall the engine.
Terminal npm i @usefluid/motionCopy these files into your project.
components/fluid/splash-choice/SplashChoice.tsx "use client"; import { shapes, springCss, springs } from "@usefluid/motion"; import { MorphPath, useSequence, useSkip } from "@usefluid/motion/react"; import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { SegmentedPicker } from "../segmented-picker/SegmentedPicker"; import styles from "./SplashChoice.module.css"; const DEFAULT_ROUTE = [shapes.dot, shapes.circle, shapes.triangle, shapes.flower, shapes.square, shapes.drop]; const BEAT = 0.36; const rise = springCss(springs.soft); export type Option = { value: string; label: string; word: string; shape: string }; type Props = { logo: string; title: string; word: string; options: Option[]; value?: string | null; onChange?(value: string): void; label?: string; route?: string[]; as?: "h1" | "h2"; play?: boolean; finished?: boolean; skippable?: boolean; onDone?(): void; }; export function SplashChoice({ logo, title, word, options, value, onChange, label = "What you want to build", 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.7]; 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 [own, setOwn] = useState<string | null>(null); const chosen = value !== undefined ? value : own; const option = options.find((o) => o.value === chosen); 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 risen = step >= last; const d = step < stepLogo ? route[step] : (option?.shape ?? logo); const shown = option?.word ?? word; return ( <div className={styles.root} data-risen={risen}> <div ref={column} className={styles.column}> <div ref={slot} className={styles.slot} style={{ transform: risen ? "none" : `translateY(${offset}px) scale(1.3)`, ...(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.line}>{title}</span> <span className={styles.word}> <span key={shown} className={styles.wordIn}>{shown}</span> </span> </Heading> <div className={styles.picker} inert={!risen}> <SegmentedPicker label={label} value={chosen ?? ""} onChange={(v) => { setOwn(v); onChange?.(v); }} options={options.map((o) => ({ value: o.value, label: o.label }))} /> </div> </div> </div> ); }components/fluid/splash-choice/SplashChoice.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%, 480px); padding: 16px; text-align: center; } .slot { width: clamp(60px, 22cqmin, 104px); 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; } .line, .word { display: block; } .word { color: var(--fluid-accent); } .wordIn { display: inline-block; animation: wordIn 380ms cubic-bezier(0.22, 1, 0.36, 1) both; } @keyframes wordIn { from { opacity: 0; filter: blur(8px); transform: translateY(0.3em); } to { opacity: 1; filter: blur(0); transform: none; } } .picker { max-width: 100%; margin-top: clamp(10px, 5cqh, 24px); } .title, .picker { transition: opacity 420ms ease, filter 420ms ease, transform 420ms cubic-bezier(0.22, 1, 0.36, 1); } .root[data-risen="false"] .title, .root[data-risen="false"] .picker { opacity: 0; filter: blur(8px); transform: translateY(10px); } .root[data-risen="true"] .picker { transition-delay: 160ms; } @media (prefers-reduced-motion: reduce) { .title, .picker, .wordIn { transition: none; animation: none; } }It uses these pieces too, so copy them alongside:
components/fluid/segmented-picker/SegmentedPicker.tsx "use client"; import { useSpring } from "@usefluid/motion/react"; import { type KeyboardEvent, type ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react"; import styles from "./SegmentedPicker.module.css"; const LEAD = { w: 34, z: 0.8 }; const TRAIL = { w: 17, z: 0.78 }; type Option<T> = { value: T; label: ReactNode; ariaLabel?: string }; type Props<T> = { label: string; value: T; onChange(value: NoInfer<T>): void; options: Option<NoInfer<T>>[] }; export function SegmentedPicker<T extends string>({ label, value, onChange, options }: Props<T>) { const root = useRef<HTMLDivElement>(null); const pill = useRef<HTMLSpanElement>(null); const [box, setBox] = useState({ left: 0, right: 0, ready: false }); const placed = useRef({ ready: false, left: 0 }); useLayoutEffect(() => { const el = root.current; if (!el) return; const measure = () => { const active = el.querySelector<HTMLElement>('[aria-checked="true"]'); if (active) setBox({ left: active.offsetLeft, right: active.offsetLeft + active.offsetWidth, ready: true }); }; measure(); const observer = new ResizeObserver(measure); observer.observe(el); return () => observer.disconnect(); }, [value]); useEffect(() => { placed.current = { ready: box.ready, left: box.left }; }, [box]); const movingRight = box.left >= placed.current.left; const immediate = !placed.current.ready; const edges = useRef({ left: 0, right: 0 }); const paint = () => { if (!pill.current) return; pill.current.style.left = `${edges.current.left}px`; pill.current.style.width = `${Math.max(0, edges.current.right - edges.current.left)}px`; }; useSpring( { v: box.left }, ({ v }) => { edges.current.left = v; paint(); }, { ...(movingRight ? TRAIL : LEAD), immediate }, ); useSpring( { v: box.right }, ({ v }) => { edges.current.right = v; paint(); }, { ...(movingRight ? LEAD : TRAIL), immediate }, ); const move = (e: KeyboardEvent, index: number) => { const delta = e.key === "ArrowRight" || e.key === "ArrowDown" ? 1 : e.key === "ArrowLeft" || e.key === "ArrowUp" ? -1 : 0; if (!delta) return; e.preventDefault(); const next = (index + delta + options.length) % options.length; onChange(options[next].value); root.current?.querySelectorAll<HTMLElement>('[role="radio"]')[next]?.focus(); }; return ( <div ref={root} role="radiogroup" aria-label={label} className={styles.root}> <span ref={pill} className={styles.pill} data-ready={box.ready} aria-hidden /> {options.map((option, i) => ( <button key={option.value} type="button" role="radio" aria-checked={option.value === value} aria-label={option.ariaLabel} tabIndex={option.value === value ? 0 : -1} className={styles.option} onClick={() => onChange(option.value)} onKeyDown={(e) => move(e, i)} > {option.label} </button> ))} </div> ); }components/fluid/segmented-picker/SegmentedPicker.module.css .root { position: relative; display: inline-flex; box-sizing: border-box; max-width: 100%; overflow-x: auto; scrollbar-width: none; padding: 4px; border-radius: calc(var(--fluid-radius) + 4px); background: color-mix(in srgb, var(--fluid-text) 8%, transparent); isolation: isolate; } .pill { position: absolute; z-index: -1; top: 4px; bottom: 4px; border-radius: var(--fluid-radius); background: var(--fluid-accent); } .pill[data-ready="false"] { visibility: hidden; } .option { min-height: 44px; min-width: 44px; padding: 0 14px; border: 0; border-radius: var(--fluid-radius); background: none; color: var(--fluid-text); font: 500 14px/1 var(--fluid-font); white-space: nowrap; cursor: pointer; touch-action: manipulation; transition: color 160ms ease; } .option[aria-checked="true"] { color: var(--fluid-on-accent); } .option:focus-visible { outline: 2px solid var(--fluid-focus); outline-offset: 2px; } @media (max-width: 480px) { .root { display: flex; width: 100%; } .option { flex: 1 0 auto; padding: 0 10px; font-size: 13px; } }Import the theme once, then point its variables at your own tokens.
layout.tsx import "@usefluid/motion/theme.css";
Usage
import { shapes } from "@usefluid/motion";
import { SplashChoice } from "./SplashChoice";
const seen = typeof window !== "undefined" && sessionStorage.getItem("intro") === "1";
<SplashChoice
logo={logo}
as="h1"
title="Made for"
word="you."
finished={seen}
onDone={() => sessionStorage.setItem("intro", "1")}
options={[
{ value: "shops", label: "Shops", word: "shops.", shape: shapes.square },
{ value: "apps", label: "Apps", word: "apps.", shape: shapes.capsule },
]}
/>API reference
| Prop | Type | Default | Description |
|---|---|---|---|
logo | string | — | Your logo path. |
title, word | string | — | The headline and its last word. |
options | { value, label, word, shape }[] | — | Each option swaps the word and the shape. |
value, onChange | — | Control the choice, or leave it alone. | |
as | "h1" | "h2" | "h2" | Use h1 when it is the page's main heading. |
route | string[] | — | Shapes before the logo. |
finished | boolean | false | Start on the final screen, for later visits. |
skippable | boolean | true | Any key or tap jumps to the end. |
onDone | () => void | — | Called when the final screen is reached. |
play | boolean | true |
Keyboard and screen readers
The picker is a radio group and stays inert until it is 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
The full opening. A timeline takes the contour through the shapes to the logo, and it rises to its place. Then each option in the picker sends the same contour to another shape and swaps the headline's word out of a blur. One body from start to end.
For your agent
For your agent
Add Splash Choice to my project with the Fluid library. Build my product's opening with SplashChoice, my logo and the areas of my business as options. 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.