A bold yellow event poster with three giant words. Every letter is a small 3D box with the letter printed on two faces: black on yellow at the front and yellow on a black tile underneath. Flipping a line turns each box a quarter turn with a slight overshoot, staggered from the first letter, the centre or the last letter depending on the line, so the word becomes a row of black tiles. The preview flips the lines in a rolling sequence. Hovering a line holds it flipped and tapping toggles it. Event details and a booking button sit under the headline and stack on phones.
// Adapted from Fancy Components (https://fancycomponents.dev/docs/components/text/letter-3d-swap)
// Copyright (c) 2024 Daniel Petho. MIT License: https://we.inc/components/licenses
import { useEffect, useState } from "react";
import { useReducedMotion } from "framer-motion";
type From = "first" | "last" | "center";
// One entry per line of the poster. `from` sets where the wave of flips starts.
const lines: { text: string; from: From }[] = [
{ text: "THROW.", from: "first" },
{ text: "GLAZE.", from: "center" },
{ text: "FIRE.", from: "last" },
];
const STAGGER_MS = 55; // delay between neighbouring letters
const FLIP_MS = 650; // how long one letter takes to turn
const DEMO_MS = 900; // pace of the self-playing preview
const details = [
{ label: "When", value: "Saturday 14 June, 10 to 4" },
{ label: "Where", value: "Unit 6, Tannery Yard" },
{ label: "Cost", value: "Free entry, wheels 18 an hour" },
];
/** A letter drawn on two faces of a box: the front, and the face underneath. Turning the box a quarter swaps them. */
function LetterBlock({ ch, flipped, delay }: { ch: string; flipped: boolean; delay: number }) {
return (
<span
className="relative inline-block motion-reduce:transition-none"
style={{
height: "1em",
transformStyle: "preserve-3d",
transform: `translateZ(-0.5em) rotateX(${flipped ? 90 : 0}deg)`,
transition: `transform ${FLIP_MS}ms cubic-bezier(0.3, 1.35, 0.5, 1) ${delay}ms`,
}}
>
<span className="block px-[0.03em] text-neutral-950" style={{ height: "1em", transform: "translateZ(0.5em)", backfaceVisibility: "hidden", WebkitBackfaceVisibility: "hidden" }}>
{ch}
</span>
<span
className="absolute inset-0 block bg-neutral-950 px-[0.03em] text-[#ffd60a]"
style={{ transform: "rotateX(-90deg) translateZ(0.5em)", backfaceVisibility: "hidden", WebkitBackfaceVisibility: "hidden" }}
>
{ch}
</span>
</span>
);
}
function FlipLine({ text, from, flipped, onEnter, onLeave, onTap }: { text: string; from: From; flipped: boolean; onEnter: () => void; onLeave: () => void; onTap: () => void }) {
const chars = [...text];
const order = (i: number) => (from === "last" ? chars.length - 1 - i : from === "center" ? Math.abs((chars.length - 1) / 2 - i) : i);
return (
<span className="block w-fit cursor-pointer" style={{ perspective: "900px" }} onPointerEnter={(e) => e.pointerType === "mouse" && onEnter()} onPointerLeave={(e) => e.pointerType === "mouse" && onLeave()} onClick={onTap}>
<span className="sr-only">{text} </span>
<span aria-hidden="true" className="inline-flex leading-none" style={{ transformStyle: "preserve-3d" }}>
{chars.map((ch, i) => (
<LetterBlock key={i} ch={ch} flipped={flipped} delay={Math.round(order(i) * STAGGER_MS)} />
))}
</span>
</span>
);
}
export default function PosterHeadlineWith3dFlippingLetterBlocks() {
const reduce = useReducedMotion();
const [step, setStep] = useState(0);
const [demo, setDemo] = useState(true);
const [manual, setManual] = useState<boolean[]>(() => lines.map(() => false));
useEffect(() => {
if (!demo || reduce) return;
const id = window.setInterval(() => setStep((s) => s + 1), DEMO_MS);
return () => window.clearInterval(id);
}, [demo, reduce]);
// In the preview each line flips one beat after the line above, holds for two beats, then flips back.
const isFlipped = (i: number) => (reduce ? false : demo ? Math.floor((step + 1 - i) / 2) % 2 === 1 : manual[i]);
const set = (i: number, value: boolean | "toggle") => {
setDemo(false);
setManual((m) => m.map((v, k) => (k === i ? (value === "toggle" ? !v : value) : demo ? false : v)));
};
return (
<section className="bg-[#ffd60a] px-6 py-14 text-neutral-950 sm:py-16">
<div className="mx-auto max-w-6xl">
<div className="flex items-center justify-between border-b-4 border-neutral-950 pb-4 font-mono text-xs font-bold uppercase tracking-widest sm:text-sm">
<span>Tannery Yard Clay Studio</span>
<span>Open day 2025</span>
</div>
<h2 className="mt-10 select-none text-[5.2rem] font-black uppercase leading-none tracking-tighter sm:text-[9rem] lg:text-[11.5rem]">
{lines.map((line, i) => (
<FlipLine key={line.text} text={line.text} from={line.from} flipped={isFlipped(i)} onEnter={() => set(i, true)} onLeave={() => set(i, false)} onTap={() => set(i, "toggle")} />
))}
</h2>
<div className="mt-10 grid gap-8 border-t-4 border-neutral-950 pt-6 lg:grid-cols-[1fr_auto] lg:items-end">
<dl className="grid gap-5 sm:grid-cols-3">
{details.map((d) => (
<div key={d.label}>
<dt className="font-mono text-xs font-bold uppercase tracking-widest">{d.label}</dt>
<dd className="mt-1 text-lg font-semibold leading-snug">{d.value}</dd>
</div>
))}
</dl>
<a href="#" className="inline-flex h-14 items-center justify-center gap-3 bg-neutral-950 px-8 text-base font-bold uppercase tracking-wide text-[#ffd60a] transition hover:bg-neutral-800 focus:outline-none focus-visible:ring-4 focus-visible:ring-neutral-950 focus-visible:ring-offset-4 focus-visible:ring-offset-[#ffd60a]">
Book a wheel
<svg aria-hidden="true" viewBox="0 0 20 20" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="square">
<path d="M4 10h12M11 5l5 5-5 5" />
</svg>
</a>
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/poster-headline-with-3d-flipping-letter-blocks.json
The letter span has `transform-style: preserve-3d` and a height of 1em. The front face is pushed forward by half that height and the second face is rotated a quarter turn and pushed out the same distance, which places it on the underside of an imaginary box. Rotating the span by 90 degrees brings the underside to the front.
Without perspective the turn is flat and looks like a squash. A `perspective` of 900px on the line gives the faces foreshortening as they rotate. Lower the value for a more dramatic effect.
Only for the `useReducedMotion` hook. The flip is a CSS transition on `transform` with a per-letter delay, so you can replace the hook with a media query and remove the dependency.
Both faces need `backface-visibility: hidden`, and no ancestor between the line and the letters may have `overflow: hidden` or a filter, because those flatten 3D children.
Yes. Pass a second character to `LetterBlock` and render it in the second face. Both words need the same number of letters, and letter widths should be similar or the tiles will be cropped.
Adapted from Letter 3D Swap in Fancy Components, used under its open-source licence. Licences and credits
Related: Rotating Word Headline with Spring Flip, Menu Links with Rolling Letter Swap on Hover, Flip Cards with Details on the Back.
More text effects 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.