Postcard Carousel
Postcard variant with a horizontally swipeable rail of multiple postcards (each with its own perforated city stamp + handwritten caption). Native scroll-snap on mobile, arrow controls on desktop, and dot pagination. Below the carousel: cursive handle, name, dotted dividers, postcard-club freebie tile, latest letter card, and stamped link buttons.
Preview
Source
tsx
"use client";
import { useEffect, useRef, useState } from "react";
import {
Instagram,
Twitter,
Youtube,
Mail,
Globe,
ShoppingBag,
PlayCircle,
Linkedin,
Music2,
ArrowUpRight,
ChevronLeft,
ChevronRight,
Stamp,
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)";
function Postcard({ item, rotation }: { item: GalleryItem; rotation: number }) {
return (
<div
className="relative shrink-0 snap-center"
style={{
background: CARD,
padding: "12px 12px 14px",
boxShadow: "0 18px 36px rgba(45,35,28,0.18)",
transform: `rotate(${rotation}deg)`,
width: "260px",
}}
>
<div
className="absolute -right-2 -top-2 flex h-12 w-10 -rotate-6 flex-col items-center justify-center text-center"
style={{
background: PAPER,
borderColor: STAMP,
color: STAMP,
boxShadow: "0 4px 8px rgba(45,35,28,0.12)",
backgroundImage: `radial-gradient(circle at 0 50%, transparent 3px, ${PAPER} 3.5px), radial-gradient(circle at 100% 50%, transparent 3px, ${PAPER} 3.5px)`,
backgroundSize: "6px 8px, 6px 8px",
backgroundRepeat: "repeat-y",
backgroundPosition: "left, right",
}}
>
<Stamp className="h-3 w-3" strokeWidth={1.6} />
<span className="mt-0.5 text-[8px] font-bold uppercase tracking-[0.15em]">
{item.caption ?? ""}
</span>
</div>
<div className="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>
<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 LinksInBioPostcardCarouselProps {
data?: LinksInBioData;
}
export default function LinksInBioPostcardCarousel({
data = defaultData,
}: LinksInBioPostcardCarouselProps) {
const items: GalleryItem[] =
(data.gallery && data.gallery.length > 0
? data.gallery
: [{ image: data.heroImage ?? data.avatar, caption: "Home" }]);
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 pt-8"
style={{
background: PAPER,
color: INK,
fontFamily: "'Inter', sans-serif",
backgroundImage: `radial-gradient(${INK}10 1px, transparent 1px)`,
backgroundSize: "16px 16px",
}}
>
{/* Carousel header */}
<div className="mx-auto mb-3 w-full max-w-[460px] px-5">
<p
className="text-[10px] font-semibold uppercase tracking-[0.3em]"
style={{ color: STAMP }}
>
Postcards from the road
</p>
<h2
className="mt-1 text-2xl"
style={{
fontFamily: "'Crimson Pro', serif",
fontWeight: 700,
color: INK,
}}
>
Where I've been writing from
</h2>
</div>
{/* Scrollable postcard rail */}
<div className="relative">
<div
ref={scrollRef}
className="flex gap-5 overflow-x-auto px-[calc(50%-130px)] pb-6 pt-4 [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))} />
</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-6 w-full max-w-[460px] px-5">
{/* Cursive handle + name */}
{data.handle && (
<p
className="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-carouselWhere to use it
An evolved postcard variant with a swipeable rail of postcards from multiple cities. Loads Caveat, Crimson Pro, and Inter from Google Fonts.
Pass a typed 'data' prop (LinksInBioData from src/registry/links-in-bio/types.ts). Provide:
- gallery — array of { image, caption, meta } where caption renders as the city stamp and meta as the handwritten note
- richBlocks — opt into freebie / latest-post tiles below the rail
In Astro:
import LinksInBioPostcardCarousel from '../components/innovations/links-in-bio/postcard-carousel';
<LinksInBioPostcardCarousel client:load data={myProfile} />
In Next.js:
import LinksInBioPostcardCarousel from '@/components/innovations/links-in-bio/postcard-carousel';
Best for: travel writers, expats, lifestyle creators with a "from the road" vibe. Each postcard is slightly rotated (alternating tilt) for a tactile feel.