Postcard Carousel — Storyteller
Postcard carousel layered onto a full-bleed hero photo backdrop with the section header (eyebrow + title) overlaid on the image. The polaroid postcards (each pinned with a colored tack) overlap into the cream paper area below. Avatar circle then sits above the cursive handle and serif name — magazine-style identity stack — followed by the freebie tile, latest letter, link buttons, and socials.
Preview
Source
tsx
"use client";
import { useEffect, useRef, useState } from "react";
import {
Instagram,
Twitter,
Youtube,
Mail,
Globe,
ShoppingBag,
PlayCircle,
Linkedin,
Music2,
ArrowUpRight,
ChevronLeft,
ChevronRight,
type LucideIcon,
} from "lucide-react";
import type { GalleryItem, LinkIcon, LinksInBioData, RichBlock } from "../types";
import { defaultData } from "../defaultData";
const ICONS: Record<LinkIcon, LucideIcon> = {
instagram: Instagram,
twitter: Twitter,
tiktok: Music2,
youtube: Youtube,
spotify: Music2,
apple: Music2,
linkedin: Linkedin,
email: Mail,
globe: Globe,
shop: ShoppingBag,
play: PlayCircle,
};
const PAPER = "#f5ecd9";
const CARD = "#fbf6e7";
const INK = "#2d231c";
const STAMP = "#a85a3c";
const RULE = "rgba(45,35,28,0.18)";
const TACKS = [
{ src: "/tacks/red-tack.png", side: "right", offset: 6 },
{ src: "/tacks/yellow-tack.png", side: "left", offset: 6 },
{ src: "/tacks/blue-tack.png", side: "right", offset: 6 },
] as const;
function Postcard({ item, rotation, index }: { item: GalleryItem; rotation: number; index: number }) {
const tack = TACKS[index % TACKS.length];
const tackTilt = index % 2 === 0 ? -8 : 6;
return (
<div
className="relative shrink-0 snap-center"
style={{
background: "#ffffff",
padding: "12px 12px 14px",
boxShadow: "0 18px 36px rgba(45,35,28,0.18)",
transform: `rotate(${rotation}deg)`,
width: "260px",
}}
>
<div className="relative overflow-hidden" style={{ aspectRatio: "5 / 4" }}>
<img
src={item.image}
alt={item.caption ?? ""}
width={800}
height={640}
loading="lazy"
className="h-full w-full object-cover"
style={{ filter: "saturate(0.92) contrast(1.02) sepia(0.05)" }}
/>
</div>
<img
src={tack.src}
alt=""
aria-hidden="true"
className="pointer-events-none absolute"
style={{
top: -18,
[tack.side]: tack.offset,
width: 60,
height: "auto",
overflow: "visible",
transform: `rotate(${tackTilt}deg)`,
filter: "drop-shadow(0 4px 6px rgba(45,35,28,0.4))",
zIndex: 30,
}}
/>
<div className="mt-2 px-1">
{item.caption && (
<p
className="text-base leading-tight"
style={{
fontFamily: "'Caveat', cursive",
color: INK,
fontSize: "1.2rem",
}}
>
{item.caption}
</p>
)}
{item.meta && (
<p
className="mt-0.5 line-clamp-2 text-[11px]"
style={{ color: "rgba(45,35,28,0.65)" }}
>
{item.meta}
</p>
)}
</div>
</div>
);
}
export interface LinksInBioPostcardCarouselStorytellerProps {
data?: LinksInBioData;
}
export default function LinksInBioPostcardCarouselStoryteller({
data = defaultData,
}: LinksInBioPostcardCarouselStorytellerProps) {
const items: GalleryItem[] =
(data.gallery && data.gallery.length > 0
? data.gallery
: [{ image: data.heroImage ?? data.avatar, caption: "Home" }]);
const heroImage = data.heroImage ?? data.avatar;
const [active, setActive] = useState(0);
const scrollRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const onScroll = () => {
const cards = el.querySelectorAll<HTMLDivElement>("[data-postcard]");
let bestIdx = 0;
let bestDist = Infinity;
const center = el.scrollLeft + el.clientWidth / 2;
cards.forEach((c, i) => {
const cardCenter = c.offsetLeft + c.offsetWidth / 2;
const d = Math.abs(cardCenter - center);
if (d < bestDist) {
bestDist = d;
bestIdx = i;
}
});
setActive(bestIdx);
};
el.addEventListener("scroll", onScroll, { passive: true });
return () => el.removeEventListener("scroll", onScroll);
}, []);
function scrollTo(idx: number) {
const el = scrollRef.current;
if (!el) return;
const cards = el.querySelectorAll<HTMLDivElement>("[data-postcard]");
const target = cards[idx];
if (target) {
el.scrollTo({
left: target.offsetLeft - el.clientWidth / 2 + target.offsetWidth / 2,
behavior: "smooth",
});
}
}
const freebie = data.richBlocks?.find(
(b): b is Extract<RichBlock, { kind: "freebie" }> => b.kind === "freebie"
);
const latestPost = data.richBlocks?.find(
(b): b is Extract<RichBlock, { kind: "latest-post" }> => b.kind === "latest-post"
);
return (
<>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Caveat:[email protected]&family=Crimson+Pro:ital,wght@0,400..700;1,400..700&family=Inter:wght@400;500;600&display=swap"
/>
<section
className="min-h-screen w-full pb-12"
style={{
color: INK,
fontFamily: "'Inter', sans-serif",
backgroundColor: PAPER,
backgroundImage: `linear-gradient(180deg, rgba(245,236,217,0.55) 0%, rgba(245,236,217,0.82) 55%, rgba(245,236,217,0.94) 100%), url(${heroImage})`,
backgroundSize: "cover",
backgroundPosition: "center top",
backgroundAttachment: "fixed",
backgroundRepeat: "no-repeat",
}}
>
{/* Hero photo backdrop with eyebrow + title overlaid */}
<div className="relative w-full overflow-hidden" style={{ height: "440px" }}>
<img
src={heroImage}
alt=""
width={1600}
height={900}
loading="eager"
className="h-full w-full object-cover"
/>
<div
aria-hidden
className="absolute inset-0"
style={{
background: `linear-gradient(180deg, rgba(45,35,28,0.35) 0%, rgba(45,35,28,0.05) 35%, ${PAPER} 100%)`,
}}
/>
<div className="absolute inset-x-0 top-8 px-5 text-center">
<p
className="text-[10px] font-semibold uppercase tracking-[0.3em]"
style={{ color: "#fbf6e7", textShadow: "0 2px 8px rgba(0,0,0,0.4)" }}
>
Postcards from the road
</p>
<h2
className="mt-1 text-2xl"
style={{
fontFamily: "'Crimson Pro', serif",
fontWeight: 700,
color: "#ffffff",
textShadow: "0 2px 12px rgba(0,0,0,0.45)",
}}
>
Where I've been writing from
</h2>
</div>
</div>
{/* Scrollable postcard rail — pulled up to overlap the hero photo */}
<div className="relative" style={{ marginTop: "-260px" }}>
<div
ref={scrollRef}
className="flex gap-5 overflow-x-auto px-[calc(50%-130px)] pb-16 pt-10 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden snap-x snap-mandatory"
style={{ scrollSnapType: "x mandatory" }}
>
{items.map((item, i) => (
<div key={i} data-postcard>
<Postcard item={item} rotation={(i % 2 === 0 ? -1 : 1) * (1 + (i % 3))} index={i} />
</div>
))}
</div>
{/* Desktop arrow controls */}
<button
type="button"
aria-label="Previous postcard"
onClick={() => scrollTo(Math.max(0, active - 1))}
className="absolute left-3 top-1/2 hidden -translate-y-1/2 items-center justify-center rounded-full sm:flex"
style={{
width: 36,
height: 36,
background: CARD,
border: `1px solid ${RULE}`,
color: INK,
boxShadow: "0 6px 14px rgba(45,35,28,0.12)",
}}
>
<ChevronLeft className="h-4 w-4" />
</button>
<button
type="button"
aria-label="Next postcard"
onClick={() => scrollTo(Math.min(items.length - 1, active + 1))}
className="absolute right-3 top-1/2 hidden -translate-y-1/2 items-center justify-center rounded-full sm:flex"
style={{
width: 36,
height: 36,
background: CARD,
border: `1px solid ${RULE}`,
color: INK,
boxShadow: "0 6px 14px rgba(45,35,28,0.12)",
}}
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
{/* Pagination dots */}
<div className="mt-2 flex items-center justify-center gap-2">
{items.map((_, i) => (
<button
key={i}
type="button"
aria-label={`Postcard ${i + 1}`}
onClick={() => scrollTo(i)}
className="rounded-full transition-all"
style={{
width: i === active ? 22 : 8,
height: 8,
background: i === active ? STAMP : "rgba(45,35,28,0.25)",
}}
/>
))}
</div>
<div className="mx-auto mt-8 w-full max-w-[460px] px-5">
{/* Avatar circle */}
<div className="flex flex-col items-center text-center">
<img
src={data.avatar}
alt={data.name}
width={140}
height={140}
loading="eager"
className="h-32 w-32 rounded-full object-cover"
style={{
border: `5px solid ${PAPER}`,
boxShadow: "0 18px 38px rgba(45,35,28,0.22)",
}}
/>
</div>
{/* Cursive handle + name */}
{data.handle && (
<p
className="mt-4 text-center"
style={{
fontFamily: "'Caveat', cursive",
fontSize: "1.6rem",
color: STAMP,
lineHeight: 1.1,
}}
>
{data.handle}
</p>
)}
<h1
className="mt-1 text-center"
style={{
fontFamily: "'Crimson Pro', serif",
fontWeight: 700,
fontSize: "clamp(2rem, 6.4vw, 2.6rem)",
color: INK,
}}
>
{data.name}
{data.verified && (
<span className="ml-2 align-middle text-base" style={{ color: STAMP }}>
✓
</span>
)}
</h1>
{data.bio && (
<p
className="mx-auto mt-3 max-w-[400px] text-center text-[15px] leading-relaxed"
style={{ color: "rgba(45,35,28,0.75)" }}
>
{data.bio}
</p>
)}
{/* Dotted divider */}
<div
aria-hidden
className="my-6 h-px w-full"
style={{
backgroundImage: `linear-gradient(to right, ${RULE} 50%, transparent 50%)`,
backgroundSize: "8px 1px",
}}
/>
{/* Quick freebie tile */}
{freebie && (
<div
className="rounded-xl p-5"
style={{ background: CARD, border: `2px dashed ${STAMP}` }}
>
<p
className="text-[10px] font-bold uppercase tracking-[0.3em]"
style={{ color: STAMP }}
>
Postcard club
</p>
<p
className="mt-1.5 text-lg leading-snug"
style={{ fontFamily: "'Crimson Pro', serif", fontWeight: 600 }}
>
{freebie.title}
</p>
{freebie.description && (
<p className="mt-1.5 text-sm" style={{ color: "rgba(45,35,28,0.7)" }}>
{freebie.description}
</p>
)}
<p className="mt-2 text-[12px]" style={{ color: STAMP }}>
A real paper postcard, mailed once a season. Free.
</p>
</div>
)}
{latestPost && (
<a
href={latestPost.href}
className="mt-4 grid grid-cols-[110px_1fr] gap-3 rounded-xl p-3 transition-transform hover:-translate-y-0.5"
style={{ background: CARD, border: `1px dashed ${RULE}` }}
>
<div className="overflow-hidden rounded-sm" style={{ aspectRatio: "1/1" }}>
<img
src={latestPost.image}
alt=""
width={300}
height={300}
loading="lazy"
className="h-full w-full object-cover"
/>
</div>
<div className="flex flex-col justify-center">
<p
className="text-[10px] font-semibold uppercase tracking-[0.25em]"
style={{ color: STAMP }}
>
This week's letter
</p>
<p
className="mt-1 line-clamp-2 text-base leading-snug"
style={{ fontFamily: "'Crimson Pro', serif", fontWeight: 600 }}
>
{latestPost.title}
</p>
{latestPost.meta && (
<p className="mt-1 text-[11px]" style={{ color: "rgba(45,35,28,0.6)" }}>
{latestPost.meta}
</p>
)}
</div>
</a>
)}
{/* Stamped buttons */}
<ul className="mt-5 space-y-2">
{data.links.slice(0, 4).map((link) => {
const Icon = link.icon ? ICONS[link.icon] : ArrowUpRight;
return (
<li key={link.label}>
<a
href={link.href}
className="flex items-center gap-3 rounded-lg px-4 py-3.5 transition-transform hover:translate-x-0.5"
style={{
background: CARD,
border: `1px dashed ${RULE}`,
color: INK,
}}
>
<span
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full"
style={{ background: PAPER, color: STAMP }}
>
<Icon className="h-3.5 w-3.5" strokeWidth={1.8} />
</span>
<span className="flex-1 truncate text-[14px]">{link.label}</span>
{link.badge && (
<span
className="shrink-0 rounded-sm px-2 py-0.5 text-[10px] font-bold uppercase tracking-[0.18em]"
style={{ background: STAMP, color: PAPER }}
>
{link.badge}
</span>
)}
<ArrowUpRight className="h-4 w-4 shrink-0" style={{ color: STAMP, opacity: 0.7 }} />
</a>
</li>
);
})}
</ul>
{data.socials && data.socials.length > 0 && (
<div className="mt-7 flex flex-wrap items-center justify-center gap-3">
{data.socials.map((s, i) => {
const Icon = ICONS[s.type] ?? Globe;
return (
<a
key={i}
href={s.href}
aria-label={s.type}
className="flex h-9 w-9 items-center justify-center rounded-full transition-transform hover:-rotate-6"
style={{
background: CARD,
border: `1.5px solid ${STAMP}`,
color: STAMP,
}}
>
<Icon className="h-4 w-4" strokeWidth={1.8} />
</a>
);
})}
</div>
)}
<p
className="mt-7 text-center"
style={{
fontFamily: "'Caveat', cursive",
fontSize: "1.1rem",
color: STAMP,
opacity: 0.8,
}}
>
xo, from somewhere.
</p>
</div>
</section>
</>
);
} Claude Code Instructions
CLI Install
npx innovations add postcard-carousel-storytellerWhere to use it
A storyteller-flavored postcard carousel. Combines the swipeable polaroid rail of postcard-carousel with the storyteller pattern of a hero image at top + circular avatar overlap above the name. Loads Caveat, Crimson Pro, and Inter from Google Fonts.
Pass a typed 'data' prop (LinksInBioData from src/registry/links-in-bio/types.ts). Provide:
- heroImage — wide image used as the backdrop the postcards float on top of (falls back to avatar)
- avatar — circular portrait that sits above the name
- gallery — array of { image, caption, meta } for the postcard rail
- richBlocks — opt into freebie / latest-post tiles below the rail
Tack PNGs are served from /tacks/{red,yellow,blue}-tack.png in /public.
In Astro:
import LinksInBioPostcardCarouselStoryteller from '../components/innovations/links-in-bio/postcard-carousel-storyteller';
<LinksInBioPostcardCarouselStoryteller client:load data={myProfile} />
In Next.js:
import LinksInBioPostcardCarouselStoryteller from '@/components/innovations/links-in-bio/postcard-carousel-storyteller';
Best for: travel writers, photographers, and lifestyle authors who want a stronger sense of place — the hero photo establishes mood, the polaroids stack on top, and the avatar/name identity block reads like a magazine masthead.