A centred editorial headline for a furniture maker, set in three lines of large light type. In the gap between two words of each line, a pill-shaped picture springs open and pushes the words apart, then closes again. Pointing at a line opens its picture, tapping toggles it, and the preview opens the three in turn until someone interacts. The pictures are small gradient scenes sized in em units, so they scale with the type. A paragraph, two buttons and three facts sit underneath. On phones the headline shrinks so each line still fits with its picture open.
// Adapted from Fancy Components (https://fancycomponents.dev/docs/components/blocks/media-between-text)
// Copyright (c) 2024 Daniel Petho. MIT License: https://we.inc/components/licenses
import { useEffect, useState } from "react";
import { motion, useReducedMotion } from "framer-motion";
// One entry per line: the words either side of the picture, and the picture itself.
// Each picture is drawn with a gradient and two shapes. Swap the inner markup for an <img> to use photographs.
const lines = [
{ before: "Furniture", after: "made", sky: "linear-gradient(120deg,#e7b97a,#b5652f 60%,#6b2f14)", orb: "#fff1cf", ground: "#3d1a0b", label: "an oak chair in morning light" },
{ before: "by four", after: "hands", sky: "linear-gradient(120deg,#cfe3c6,#7fa37a 60%,#31553a)", orb: "#fdfbe8", ground: "#1e3626", label: "two makers at the workbench" },
{ before: "in one", after: "barn.", sky: "linear-gradient(120deg,#c9def2,#7ea6d6 60%,#35527d)", orb: "#ffffff", ground: "#1c2f4d", label: "the workshop on the hill" },
];
const OPEN_WIDTH = "2.1em"; // picture width when open, relative to the headline's font size
const DEMO_MS = 1300; // time each picture stays open while the preview plays on its own
const facts = [
{ value: "14", label: "pieces in the range" },
{ value: "9 weeks", label: "from order to door" },
{ value: "25 years", label: "guarantee on every joint" },
];
export default function HeadlineWithPicturesThatOpenBetweenWords() {
const reduce = useReducedMotion();
const [demo, setDemo] = useState(0);
const [playing, setPlaying] = useState(true);
const [open, setOpen] = useState<number | null>(null);
useEffect(() => {
if (!playing || reduce) return;
const id = window.setInterval(() => setDemo((d) => (d + 1) % lines.length), DEMO_MS);
return () => window.clearInterval(id);
}, [playing, reduce]);
const isOpen = (i: number) => (reduce ? true : playing ? demo === i : open === i);
const point = (i: number | null) => {
setPlaying(false);
setOpen(i);
};
return (
<section className="bg-[#efe9df] px-6 py-16 text-[#2d2a26] sm:py-24">
<div className="mx-auto max-w-6xl">
<p className="text-center font-mono text-xs uppercase tracking-[0.3em] text-[#a2431f]">Hartwell and Daughter / Joiners since 1998</p>
<h2 className="mt-10 text-[2rem] font-light leading-[1.12] tracking-tight sm:text-7xl lg:text-8xl">
<span className="sr-only">Furniture made by four hands in one barn.</span>
{lines.map((line, i) => (
<span
key={line.before}
aria-hidden="true"
className="flex cursor-default items-center justify-center whitespace-nowrap"
onPointerEnter={(e) => e.pointerType === "mouse" && point(i)}
onPointerLeave={(e) => e.pointerType === "mouse" && point(null)}
onClick={() => point(open === i && !playing ? null : i)}
>
<span>{line.before}</span>
<motion.span
className="relative mx-[0.12em] block h-[0.78em] shrink-0 overflow-hidden rounded-full"
initial={false}
animate={{ width: isOpen(i) ? OPEN_WIDTH : "0em", marginLeft: isOpen(i) ? "0.22em" : "0.12em", marginRight: isOpen(i) ? "0.22em" : "0.12em" }}
transition={reduce ? { duration: 0 } : { type: "spring", duration: 0.55, bounce: 0.12 }}
>
{/* The picture keeps its full size and is revealed by the wrapper, so it does not squash as it opens. */}
<span className="absolute left-1/2 top-0 block h-full -translate-x-1/2" style={{ width: OPEN_WIDTH, background: line.sky }}>
<span className="absolute left-[22%] top-[18%] h-[38%] w-[14%] rounded-full" style={{ background: line.orb }} />
<span className="absolute -bottom-[55%] left-[-10%] h-full w-[120%] rounded-[50%]" style={{ background: line.ground }} />
</span>
</motion.span>
<span className={i === 2 ? "font-serif italic text-[#a2431f]" : ""}>{line.after}</span>
</span>
))}
</h2>
<div className="mx-auto mt-12 max-w-xl text-center">
<p className="text-lg leading-relaxed text-[#5a544c]">
Tables, chairs and shelves cut from English oak and ash, built to order by a father and daughter. Point at a line to see where it happens.
</p>
<div className="mt-8 flex flex-wrap justify-center gap-3">
<a href="#" className="inline-flex h-12 items-center rounded-full bg-[#2d2a26] px-7 text-sm font-semibold text-[#efe9df] transition hover:bg-[#45403a] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#2d2a26] focus-visible:ring-offset-2 focus-visible:ring-offset-[#efe9df]">
See the range
</a>
<a href="#" className="inline-flex h-12 items-center rounded-full border border-[#2d2a26]/40 px-7 text-sm font-semibold transition hover:border-[#2d2a26] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#2d2a26] focus-visible:ring-offset-2 focus-visible:ring-offset-[#efe9df]">
Visit the workshop
</a>
</div>
</div>
<dl className="mx-auto mt-14 grid max-w-3xl gap-6 border-t border-[#2d2a26]/20 pt-8 text-center sm:grid-cols-3">
{facts.map((f) => (
<div key={f.label}>
<dt className="sr-only">{f.label}</dt>
<dd>
<span className="block font-serif text-3xl">{f.value}</span>
<span className="mt-1 block text-sm text-[#5a544c]">{f.label}</span>
</dd>
</div>
))}
</dl>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/headline-with-pictures-that-open-between-words.json
Each line is a centred flex row of three items: the first word, the picture wrapper and the second word. The wrapper's width animates from zero to its open size. Because the row is centred, both words slide outward as it grows.
The wrapper is what changes width, and it clips its content. The picture inside always has the full open width and is centred, so opening works like curtains being drawn back.
Width changes cause layout, unlike transforms. Here the layout involved is one short line of text, so it stays smooth. Avoid using the same technique on a wrapper that would reflow a whole page.
Yes. Put a muted, looping, inline `video` inside the wrapper with `h-full w-full object-cover`. Start playback when the line opens and pause it when it closes to save battery.
Use `useInView` from framer-motion on the heading and treat every line as open once it is in view, with a small delay per line for a staggered reveal.
Adapted from Media Between Text in Fancy Components, used under its open-source licence. Licences and credits
Related: Marker Highlight Text That Sweeps Across Phrases, Rotating Word Headline with Spring Flip, Editorial Hero with Sliding Letters and Drawn Underline.
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.