A way to show a website being changed with a sentence, without a single screenshot of an editor. A florist's homepage is painted on a thick lacquered board that lies tilted on a pale table. Above it sits a black bar holding the instruction, for example Make the header warmer, with a small well of paint at its left end. When the sentence is finished a glossy bead swells out of the underside of the bar, lets go, and falls. Its shadow tightens on the board as it comes down, the board dips under the hit, and the new colour runs outward from the landing point as a circle with a thick shining lip, kept inside the part that was named. The fresh coat carries a wet streak of light for about two seconds and then dries flat. Four pill buttons under the board say the next change: header, button, whole page, or start again. On a phone the bar and the buttons keep their full size and only the board is scaled down.
import { useEffect, useRef, useState } from "react";
import { useReducedMotion } from "framer-motion";
/** Everything painted on the board. Change the business here. */
const site = {
brand: "Halloway Flowers",
links: ["Bouquets", "Weddings", "Visit"],
headline: ["Flowers cut", "this morning."],
sub: "Hand-tied in the shop on Mill Lane, ready by noon.",
cta: "Order a bouquet",
photo: { src: "https://images.pexels.com/photos/8127839/pexels-photo-8127839.jpeg?auto=compress&cs=tinysrgb&w=600", alt: "Deep red peonies in a steel vase against a grey wall" },
};
const BLUE = "#4F98FB";
const INK = "#0B0D12";
const PAPER = "#FBFAF7";
const WARM = "#DC7440";
const COOL = "#E3E6EA";
const NIGHT = "#12151C";
const sans = '"Inter Tight", "Inter", ui-sans-serif, system-ui, sans-serif';
type Look = { header: 0 | 1; button: 0 | 1; body: 0 | 1 };
type Part = "header" | "button" | "body" | "reset";
const FRESH: Look = { header: 0, button: 0, body: 0 };
// Design pixels. The board is drawn at this size and the scene is scaled to fit.
const W = 560;
const H = 390;
const SCENE_W = 900;
const SCENE_H = 420;
/** Where each coat lands, and the shape it has to stay inside. */
const zones: Record<Part, { x: number; y: number; w: number; h: number; radius: string; cx: number; cy: number }> = {
header: { x: 0, y: 0, w: W, h: 58, radius: "14px 14px 0 0", cx: 300, cy: 29 },
button: { x: 36, y: 300, w: 184, h: 52, radius: "26px", cx: 128, cy: 326 },
body: { x: 0, y: 0, w: W, h: H, radius: "14px", cx: 250, cy: 210 },
reset: { x: 0, y: 0, w: W, h: H, radius: "14px", cx: 280, cy: 190 },
};
const chips: { part: Part; label: string }[] = [
{ part: "header", label: "Header" },
{ part: "button", label: "Button" },
{ part: "body", label: "Page" },
{ part: "reset", label: "Start again" },
];
/** What saying it does: the sentence, the look afterwards and the colour of the bead. */
function plan(look: Look, part: Part): { say: string; next: Look; paint: string } {
if (part === "header") return look.header ? { say: "Cool the header down.", next: { ...look, header: 0 }, paint: look.body ? "#232936" : COOL } : { say: "Make the header warmer.", next: { ...look, header: 1 }, paint: WARM };
if (part === "button") return look.button ? { say: "Make the button plain.", next: { ...look, button: 0 }, paint: look.body ? PAPER : INK } : { say: "Turn the button blue.", next: { ...look, button: 1 }, paint: BLUE };
if (part === "body") return look.body ? { say: "Make the page light.", next: { ...look, body: 0 }, paint: PAPER } : { say: "Make the page dark.", next: { ...look, body: 1 }, paint: NIGHT };
return { say: "Start again in white.", next: FRESH, paint: PAPER };
}
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
const easeOut = (t: number) => 1 - Math.pow(1 - t, 3);
/** The page as it is painted on the board, for one look. */
function Face({ look, hidden }: { look: Look; hidden?: boolean }) {
const dark = look.body === 1;
const text = dark ? "#F6F4EF" : INK;
const soft = dark ? "#A9B0BF" : "#565B66";
const head = look.header ? WARM : dark ? "#232936" : COOL;
const headText = look.header ? INK : text;
const btn = look.button ? BLUE : dark ? PAPER : INK;
const btnText = look.button ? INK : dark ? INK : "#FFFFFF";
const Title = hidden ? "p" : "h2";
return (
<div aria-hidden={hidden ? "true" : undefined} className="absolute inset-0" style={{ background: dark ? NIGHT : PAPER }}>
<div className="absolute inset-x-0 top-0 flex items-center justify-between px-[26px]" style={{ height: 58, background: head, color: headText }}>
<span className="flex items-center gap-[9px] text-[15px] font-extrabold tracking-[-0.025em]">
<span aria-hidden="true" className="h-[10px] w-[10px] rounded-full" style={{ background: look.header ? INK : BLUE }} />
{site.brand}
</span>
<span className="flex items-center gap-[20px] text-[11.5px] font-bold">
{site.links.map((l) => (
<span key={l}>{l}</span>
))}
</span>
</div>
<Title className="absolute left-[36px] top-[98px] text-[50px] font-black leading-[0.95] tracking-[-0.055em]" style={{ color: text }}>
{site.headline.map((l) => (
<span key={l} className="block">
{l}
</span>
))}
</Title>
<p className="absolute left-[38px] top-[216px] w-[250px] text-[13.5px] font-semibold leading-snug" style={{ color: soft }}>
{site.sub}
</p>
<span className="absolute flex items-center justify-center gap-2 text-[15px] font-extrabold tracking-[-0.02em]" style={{ left: 36, top: 300, width: 184, height: 52, borderRadius: 26, background: btn, color: btnText }}>
{site.cta}
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 10h12M11 5l5 5-5 5" />
</svg>
</span>
<div className="absolute overflow-hidden" style={{ left: 352, top: 86, width: 176, height: 270, borderRadius: 12, background: "#8d8f93", boxShadow: dark ? "0 0 0 1px rgba(255,255,255,0.10)" : "0 0 0 1px rgba(11,13,18,0.08)" }}>
<img src={site.photo.src} alt={hidden ? "" : site.photo.alt} width={176} height={270} draggable={false} className="h-full w-full object-cover" />
</div>
</div>
);
}
export default function PaintBeadPageEdit() {
const reduce = useReducedMotion();
const stageRef = useRef<HTMLDivElement>(null);
const pillRef = useRef<HTMLDivElement>(null);
const typedRef = useRef<HTMLSpanElement>(null);
const swatchRef = useRef<HTMLSpanElement>(null);
const wrapRef = useRef<HTMLDivElement>(null);
const sceneRef = useRef<HTMLDivElement>(null);
const boardRef = useRef<HTMLDivElement>(null);
const coatRef = useRef<HTMLDivElement>(null);
const rimBoxRef = useRef<HTMLDivElement>(null);
const rimRef = useRef<SVGSVGElement>(null);
const spotRef = useRef<HTMLDivElement>(null);
const wetRef = useRef<HTMLDivElement>(null);
const beadRef = useRef<HTMLDivElement>(null);
const first = plan(FRESH, "header");
const [cur, setCur] = useState<Look>(reduce ? first.next : FRESH);
const [next, setNext] = useState<Look>(first.next);
const [status, setStatus] = useState("");
const api = useRef<{ say: (p: Part) => void } | null>(null);
useEffect(() => {
const stage = stageRef.current, pill = pillRef.current, typed = typedRef.current, swatch = swatchRef.current, wrap = wrapRef.current, scene = sceneRef.current;
const board = boardRef.current, coat = coatRef.current, rimBox = rimBoxRef.current, rim = rimRef.current, spot = spotRef.current, bead = beadRef.current, wet = wetRef.current;
if (!stage || !pill || !typed || !swatch || !wrap || !scene || !board || !coat || !rimBox || !rim || !spot || !bead || !wet) return;
// one coat of paint from sentence to dry
const s = {
look: reduce ? first.next : FRESH, // what is dry on the board
to: first.next,
part: "header" as Part,
say: first.say,
paint: first.paint,
phase: reduce ? "rest" : "swell",
t0: performance.now() + 500,
slow: 2.3, // the first coat runs slower so a still picture catches it half way
x0: 0, y0: 0, x1: 0, y1: 0,
R: 0,
dip: 0, vdip: 0,
px: 0, py: 0, tx: 0, ty: 0,
scale: 1,
demo: 0,
user: -1e9,
held: false,
ph: [0.4, 2.1, 4.4],
};
let raf = 0;
let visible = true;
let last = performance.now();
const TYPE = 26, SWELL = 400, FALL = 380, SPREAD = 1500, DRY = 1900, GAP = 650;
const measure = () => {
const sc = clamp(wrap.clientWidth / 700, 0.42, 1.2);
s.scale = sc;
wrap.style.height = `${Math.round(SCENE_H * sc)}px`;
scene.style.transform = `translateX(-50%) scale(${sc.toFixed(4)})`;
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(wrap);
const lips = rim.querySelectorAll("path");
/** The coat is a puddle, not a compass circle: its edge runs ahead in some directions and lags in others. */
const setCoat = (r: number, wetness: number) => {
const z = zones[s.part];
const lag = 0.11 * (1 - (r / (s.R || 1)) * 0.6);
let d = "";
for (let i = 0; i < 44; i++) {
const a = (i / 44) * Math.PI * 2;
const rr = r * (1 + lag * (Math.sin(3 * a + s.ph[0]) * 0.5 + Math.sin(5 * a + s.ph[1]) * 0.32 + Math.sin(8 * a + s.ph[2]) * 0.18));
d += `${i ? "L" : "M"}${(z.cx + Math.cos(a) * rr).toFixed(1)} ${(z.cy + Math.sin(a) * rr).toFixed(1)}`;
}
d += "Z";
coat.style.clipPath = `path("${d}")`;
wet.style.clipPath = `path("${d}")`;
wet.style.opacity = wetness.toFixed(3);
lips.forEach((l) => l.setAttribute("d", d));
};
const aim = () => {
const z = zones[s.part];
rimBox.style.left = `${z.x}px`;
rimBox.style.top = `${z.y}px`;
rimBox.style.width = `${z.w}px`;
rimBox.style.height = `${z.h}px`;
rimBox.style.borderRadius = z.radius;
rim.style.left = `${-z.x}px`;
rim.style.top = `${-z.y}px`;
wet.style.left = `${-z.x}px`;
wet.style.top = `${-z.y}px`;
s.ph = [Math.random() * 6.3, Math.random() * 6.3, Math.random() * 6.3];
spot.style.left = `${z.cx}px`;
spot.style.top = `${z.cy}px`;
s.R = Math.hypot(Math.max(z.cx - z.x, z.x + z.w - z.cx), Math.max(z.cy - z.y, z.y + z.h - z.cy)) * 1.14 + 8;
swatch.style.background = s.paint;
bead.style.setProperty("--paint", s.paint);
};
/** Where the bead leaves the bar and where it lands, in stage pixels. */
const sight = () => {
const sr = stage.getBoundingClientRect(), pr = pill.getBoundingClientRect(), tr = spot.getBoundingClientRect();
s.x1 = tr.left + tr.width / 2 - sr.left;
s.y1 = tr.top + tr.height / 2 - sr.top;
s.x0 = clamp(s.x1, pr.left - sr.left + 44, pr.right - sr.left - 44);
s.y0 = pr.bottom - sr.top;
};
const begin = (part: Part, now: number, typeIt: boolean) => {
// whatever was wet becomes the dry coat underneath
s.look = s.to;
setCur(s.to);
const p = plan(s.look, part);
s.part = part;
s.to = p.next;
s.say = p.say;
s.paint = p.paint;
aim();
setCoat(0, 0);
rim.style.opacity = "0";
setNext(p.next);
setStatus(p.say);
if (reduce) {
typed.textContent = p.say;
s.look = p.next;
setCur(p.next);
s.phase = "rest";
return;
}
s.slow = 1;
s.phase = typeIt ? "type" : "swell";
s.t0 = now;
};
aim();
setCoat(0, 0);
typed.textContent = s.say;
const tick = (now: number) => {
const dt = Math.min(0.033, (now - last) / 1000);
last = now;
const t = now - s.t0;
const size = 40 * s.scale + 10;
if (s.phase === "type") {
const n = Math.min(s.say.length, Math.floor(t / TYPE));
if (typed.dataset.n !== `${s.part}${n}`) {
typed.dataset.n = `${s.part}${n}`;
typed.textContent = s.say.slice(0, n);
}
if (n >= s.say.length && t > s.say.length * TYPE + 160) {
s.phase = "swell";
s.t0 = now;
}
} else if (s.phase === "swell" && t >= 0) {
if (t < 20 || !s.x0) sight();
const k = clamp(t / SWELL, 0, 1);
const g = easeOut(k);
const sway = Math.sin(t / 60) * (1 - k) * 0.06;
bead.style.opacity = "1";
bead.style.width = `${size}px`;
bead.style.height = `${size}px`;
bead.style.transform = `translate(${(s.x0 - size / 2).toFixed(1)}px, ${(s.y0 - size * (1 - 0.72 * g)).toFixed(1)}px) scale(${(0.35 + 0.65 * g + sway).toFixed(3)}, ${(0.35 + 0.65 * g + 0.22 * k * k).toFixed(3)})`;
if (k >= 1) {
sight();
s.phase = "fall";
s.t0 = now;
}
} else if (s.phase === "fall") {
const k = clamp(t / FALL, 0, 1);
const y = s.y0 - size * 0.28 + (s.y1 - size * 0.5 - (s.y0 - size * 0.28)) * k * k;
const x = s.x0 + (s.x1 - s.x0) * k;
bead.style.transform = `translate(${(x - size / 2).toFixed(1)}px, ${y.toFixed(1)}px) scale(${(1 - 0.16 * k).toFixed(3)}, ${(1.1 + 0.34 * k).toFixed(3)})`;
spot.style.opacity = (0.5 * k * k).toFixed(3);
spot.style.transform = `translate(-50%, -50%) scale(${(2.6 - 1.6 * k).toFixed(3)})`;
if (k >= 1) {
bead.style.opacity = "0";
spot.style.opacity = "0";
s.vdip = -230;
s.phase = "spread";
s.t0 = now;
}
} else if (s.phase === "spread") {
const k = clamp(t / (SPREAD * s.slow), 0, 1);
// fast out of the splash, then slow like something thick
const r = s.R * (0.14 * easeOut(clamp(k * 9 * s.slow, 0, 1)) + 0.86 * (1 - Math.pow(1 - k, s.slow > 1 ? 1.25 : 2)));
setCoat(r, 1);
rim.style.opacity = clamp((1 - k) * 5, 0, 1).toFixed(3);
if (k >= 1) {
s.look = s.to;
setCur(s.to);
s.phase = "dry";
s.t0 = now;
}
} else if (s.phase === "dry") {
const k = clamp(t / DRY, 0, 1);
setCoat(s.R, 1 - easeOut(k));
if (k >= 1) {
s.phase = "rest";
s.t0 = now;
}
} else if (s.phase === "rest" && !reduce && t > GAP && now - s.user > 9000) {
s.demo = (s.demo + 1) % chips.length;
begin(chips[s.demo].part, now, true);
}
// the board takes the hit and settles; the gloss follows the pointer
s.vdip += (-430 * s.dip - 17 * s.vdip) * dt;
s.dip += s.vdip * dt;
s.px += (s.tx - s.px) * 0.07;
s.py += (s.ty - s.py) * 0.07;
const idle = reduce ? 0 : Math.sin(now / 2400) * 1.4;
board.style.transform = `rotateX(${(55 - s.py * 3).toFixed(2)}deg) rotateZ(${(-17 + s.px * 4 + idle).toFixed(2)}deg) translateZ(${s.dip.toFixed(2)}px)`;
board.style.setProperty("--gx", `${(74 + s.px * 22 + idle * 3).toFixed(1)}%`);
if (visible) raf = requestAnimationFrame(tick);
};
const io = new IntersectionObserver(([e]) => {
const was = visible;
visible = e.isIntersecting;
if (visible && !was) {
last = performance.now();
raf = requestAnimationFrame(tick);
}
});
io.observe(stage);
raf = requestAnimationFrame(tick);
const move = (e: PointerEvent) => {
const r = stage.getBoundingClientRect();
s.tx = clamp(((e.clientX - r.left) / r.width - 0.5) * 2, -1, 1);
s.ty = clamp(((e.clientY - r.top) / r.height - 0.5) * 2, -1, 1);
};
const leave = () => {
s.tx = 0;
s.ty = 0;
s.held = false;
};
const enter = () => {
s.held = true;
};
stage.addEventListener("pointermove", move);
stage.addEventListener("pointerleave", leave);
stage.addEventListener("pointerenter", enter);
api.current = {
say: (part) => {
const now = performance.now();
s.user = now;
s.demo = chips.findIndex((c) => c.part === part);
bead.style.opacity = "0";
spot.style.opacity = "0";
begin(part, now, true);
},
};
return () => {
cancelAnimationFrame(raf);
ro.disconnect();
io.disconnect();
stage.removeEventListener("pointermove", move);
stage.removeEventListener("pointerleave", leave);
stage.removeEventListener("pointerenter", enter);
api.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [reduce]);
return (
<section className="relative overflow-hidden bg-[#F6F4EF] px-4 pb-10 pt-10 sm:pb-14 sm:pt-14" style={{ fontFamily: sans }}>
<div aria-hidden="true" className="pointer-events-none absolute inset-0" style={{ background: "radial-gradient(70% 62% at 50% 58%, #FFFFFF 0%, #F6F4EF 46%, #E9E5DC 100%)" }} />
<div ref={stageRef} className="relative mx-auto w-full max-w-[900px]">
{/* the sentence */}
<div className="relative z-30 flex justify-center">
<div ref={pillRef} className="flex h-[58px] w-full max-w-[470px] items-center gap-3 rounded-full pl-[10px] pr-6 sm:h-[64px]" style={{ background: "linear-gradient(180deg, #1d212b 0%, #0B0D12 60%)", boxShadow: "inset 0 1px 0 rgba(255,255,255,0.16), inset 0 -1px 0 rgba(0,0,0,0.6), 0 1px 0 rgba(255,255,255,0.9), 0 18px 30px -14px rgba(11,13,18,0.45)" }}>
<span aria-hidden="true" className="relative h-[38px] w-[38px] shrink-0 rounded-full sm:h-[44px] sm:w-[44px]" style={{ background: "#07080b", boxShadow: "inset 0 2px 5px rgba(0,0,0,0.9), 0 1px 0 rgba(255,255,255,0.12)" }}>
<span ref={swatchRef} className="absolute inset-[5px] rounded-full" style={{ background: WARM, boxShadow: "inset 0 3px 4px rgba(255,255,255,0.45), inset 0 -4px 6px rgba(0,0,0,0.3)" }} />
</span>
<p className="min-w-0 flex-1 truncate text-[18px] font-extrabold tracking-[-0.03em] text-white sm:text-[22px]">
<span ref={typedRef}>{first.say}</span>
<span aria-hidden="true" className="edit-sentence-paint-bead-caret ml-[3px] inline-block h-[20px] w-[3px] translate-y-[3px] rounded-full sm:h-[24px]" style={{ background: BLUE }} />
</p>
</div>
</div>
{/* the bead of paint, in stage pixels */}
<div
ref={beadRef}
aria-hidden="true"
className="pointer-events-none absolute left-0 top-0 z-20"
style={{
width: 40,
height: 40,
opacity: 0,
transformOrigin: "50% 0%",
borderRadius: "50% 50% 50% 50% / 58% 58% 42% 42%",
background: "radial-gradient(circle at 34% 30%, rgba(255,255,255,0.95) 0%, rgba(255,255,255,0.9) 6%, rgba(255,255,255,0.22) 15%, rgba(255,255,255,0) 30%), radial-gradient(circle at 62% 80%, rgba(255,255,255,0.28) 0%, rgba(255,255,255,0) 34%), radial-gradient(circle at 50% 45%, rgba(0,0,0,0) 40%, rgba(0,0,0,0.30) 100%), var(--paint, #DC7440)",
boxShadow: "0 0 0 0.5px rgba(11,13,18,0.18), 0 10px 14px -6px rgba(11,13,18,0.35)",
}}
/>
<div ref={wrapRef} className="relative z-10 mt-2 w-full sm:mt-4" style={{ height: SCENE_H }}>
<div ref={sceneRef} className="absolute left-1/2 top-0" style={{ width: SCENE_W, height: SCENE_H, transformOrigin: "50% 0", transform: "translateX(-50%)", perspective: "1500px", perspectiveOrigin: "50% 20%" }}>
<div ref={boardRef} className="absolute" style={{ left: SCENE_W / 2, top: 204, width: 0, height: 0, transformStyle: "preserve-3d", transform: "rotateX(55deg) rotateZ(-17deg)", ["--gx" as string]: "74%" }}>
{/* shadow on the table */}
<div aria-hidden="true" className="absolute" style={{ left: -W / 2 + 26, top: -H / 2 + 44, width: W, height: H, borderRadius: 30, background: "rgba(60,48,26,0.5)", filter: "blur(38px)", transform: "translateZ(-70px)" }} />
<div aria-hidden="true" className="absolute" style={{ left: -W / 2 + 4, top: -H / 2 + 8, width: W, height: H, borderRadius: 16, background: "rgba(40,30,12,0.55)", filter: "blur(9px)", transform: "translateZ(-24px)" }} />
{/* the thickness of the board: raw edge under a painted face */}
{[11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1].map((i) => (
<div key={i} aria-hidden="true" className="absolute" style={{ left: -W / 2, top: -H / 2, width: W, height: H, borderRadius: 14, background: i > 9 ? "#a39a88" : i % 2 ? "#d9d2c3" : "#cdc5b4", transform: `translateZ(${-i * 2}px)` }} />
))}
<div className="absolute overflow-hidden" style={{ left: -W / 2, top: -H / 2, width: W, height: H, borderRadius: 14 }}>
<Face look={cur} />
{/* the new coat, let out from where the bead landed */}
<div ref={coatRef} className="absolute inset-0" style={{ clipPath: "circle(0px at 0px 0px)" }}>
<Face look={next} hidden />
</div>
{/* the thick wet lip at the edge of the coat */}
<div ref={rimBoxRef} aria-hidden="true" className="pointer-events-none absolute overflow-hidden">
<div ref={wetRef} className="absolute" style={{ width: W, height: H, opacity: 0, background: "linear-gradient(100deg, rgba(255,255,255,0) 20%, rgba(255,255,255,0.34) 37%, rgba(255,255,255,0.04) 47%, rgba(255,255,255,0) 58%, rgba(255,255,255,0.14) 80%, rgba(255,255,255,0) 92%), linear-gradient(180deg, rgba(255,255,255,0.10) 0%, rgba(255,255,255,0) 40%, rgba(0,0,0,0.10) 100%)" }} />
<svg ref={rimRef} className="absolute" width={W} height={H} viewBox={`0 0 ${W} ${H}`} fill="none" style={{ opacity: 0, overflow: "visible" }}>
<path stroke="rgba(11,13,18,0.30)" strokeWidth="7" transform="translate(3 4)" style={{ filter: "blur(3px)" }} />
<path stroke="rgba(255,255,255,0.20)" strokeWidth="16" />
<path stroke="rgba(255,255,255,0.78)" strokeWidth="2.2" />
</svg>
</div>
<div ref={spotRef} aria-hidden="true" className="pointer-events-none absolute h-[34px] w-[34px] rounded-full" style={{ opacity: 0, transform: "translate(-50%, -50%)", background: "radial-gradient(circle, rgba(11,13,18,0.75) 0%, rgba(11,13,18,0) 68%)" }} />
{/* lacquer: one broad band of studio light and a hard top edge */}
<div aria-hidden="true" className="pointer-events-none absolute inset-0" style={{ background: "linear-gradient(104deg, rgba(255,255,255,0) calc(var(--gx) - 30%), rgba(255,255,255,0.13) calc(var(--gx) - 6%), rgba(255,255,255,0.03) calc(var(--gx) + 4%), rgba(255,255,255,0) calc(var(--gx) + 26%))" }} />
<div aria-hidden="true" className="pointer-events-none absolute inset-0" style={{ borderRadius: 14, boxShadow: "inset 0 1.5px 0 rgba(255,255,255,0.8), inset 1.5px 0 0 rgba(255,255,255,0.45), inset 0 -2px 0 rgba(11,13,18,0.14), inset -2px 0 0 rgba(11,13,18,0.10)" }} />
</div>
</div>
</div>
</div>
{/* say it yourself */}
<div className="relative z-30 mt-6 flex flex-wrap items-center justify-center gap-2 sm:mt-8">
{chips.map((c) => (
<button
key={c.part}
type="button"
onClick={() => api.current?.say(c.part)}
aria-label={plan(cur, c.part).say}
className="inline-flex h-11 items-center gap-2 rounded-full border border-[#0B0D12]/10 bg-white px-5 text-[13px] font-extrabold tracking-[-0.01em] text-[#0B0D12] shadow-[0_1px_0_rgba(255,255,255,0.9)_inset,0_6px_14px_-8px_rgba(11,13,18,0.35)] transition hover:-translate-y-px hover:border-[#0B0D12]/25 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4F98FB] focus-visible:ring-offset-2 focus-visible:ring-offset-[#F6F4EF] active:translate-y-0"
>
<span aria-hidden="true" className="h-[10px] w-[10px] rounded-full" style={{ background: plan(cur, c.part).paint, boxShadow: "inset 0 0 0 1px rgba(11,13,18,0.18)" }} />
{c.label}
</button>
))}
</div>
<p aria-live="polite" className="sr-only">
{status}
</p>
</div>
<style>{`
@keyframes edit-sentence-paint-bead-caret-blink { 0%, 55% { opacity: 1 } 56%, 100% { opacity: 0 } }
.edit-sentence-paint-bead-caret { animation: edit-sentence-paint-bead-caret-blink 1.1s steps(1) infinite; }
@media (prefers-reduced-motion: reduce) { .edit-sentence-paint-bead-caret { animation: none; } }
`}</style>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/edit-sentence-that-gathers-into-a-bead-of-wet-paint-and-falls-onto-a-lacquered-page-model.json
Two complete copies of the page are stacked: the dry look underneath and the next look on top. The top one is cut by `clip-path: circle()` centred where the bead landed. Since the two looks differ only in the named part, the growing circle shows new colour there and nowhere else. The shining lip is a separate ring inside a box with the same shape as that part, with `overflow: hidden`.
An invisible marker sits on the board at the landing point. Just before the bead lets go, `sight` reads the marker's place on screen with `getBoundingClientRect`, which already includes the tilt and the perspective, and the fall is aimed at that pixel.
Yes. `api.current.say(part)` starts a coat for one part. Map your own parser's answer to a part name and call it, or extend `plan` so it returns a look built from what the visitor typed.
The eleven stacked layers under the face are drawn as bare board. Paint only ever reaches the top, which is what makes the dark page read as a coat on an object and not as a theme switch.
One animation frame loop that moves a bead, changes one clip path and resizes one ring. There is no canvas. The loop stops when the section leaves the screen.
Related: Pop-Up Card Where a Typed Sentence Stands Up as a Layered Paper Homepage, Proof Press with a Steel Cylinder That Rolls a Typed Brief into a Printed Homepage.
More we.inc originals or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.