Postcard
Romantic / travel-writer feel — top photo framed as a real postcard with a perforated stamp corner reading
Preview
Source
tsx
"use client";
import { useState } from "react";
import {
Instagram,
Twitter,
Youtube,
Mail,
Globe,
ShoppingBag,
PlayCircle,
Linkedin,
Music2,
ArrowUpRight,
Stamp,
Users,
CheckCircle2,
type LucideIcon,
} from "lucide-react";
import type { 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 DottedDivider() {
return (
<div
aria-hidden
className="my-6 h-px w-full"
style={{
backgroundImage: `linear-gradient(to right, ${RULE} 50%, transparent 50%)`,
backgroundSize: "8px 1px",
}}
/>
);
}
function PostcardFrame({ image, alt }: { image: string; alt: string }) {
return (
<div
className="relative mx-auto w-full max-w-[420px] -rotate-1"
style={{ background: CARD, padding: "14px 14px 18px", boxShadow: "0 22px 44px rgba(45,35,28,0.18), 0 2px 0 rgba(255,255,255,0.4) inset" }}
>
{/* postage stamp corner */}
<div
className="absolute -right-2 -top-2 flex h-14 w-12 -rotate-6 flex-col items-center justify-center text-center"
style={{
background: PAPER,
borderColor: STAMP,
color: STAMP,
boxShadow: "0 4px 10px rgba(45,35,28,0.12)",
// perforated stamp edge
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-4 w-4" strokeWidth={1.6} />
<span className="mt-0.5 text-[8px] font-bold uppercase tracking-[0.15em]">Lisbon</span>
</div>
<div
className="overflow-hidden"
style={{ aspectRatio: "5 / 4", background: "#ddd" }}
>
<img
src={image}
alt={alt}
width={1200}
height={960}
loading="eager"
className="h-full w-full object-cover"
style={{ filter: "saturate(0.92) contrast(1.02) sepia(0.05)" }}
/>
</div>
</div>
);
}
function BookTile({ block }: { block: Extract<RichBlock, { kind: "book" }> }) {
return (
<a
href={block.href}
className="group flex gap-4 rounded-xl p-4 transition-all hover:-translate-y-0.5"
style={{ background: CARD, border: `1px dashed ${RULE}` }}
>
<div
className="overflow-hidden rounded-sm"
style={{ width: "84px", height: "126px", boxShadow: "0 12px 24px rgba(45,35,28,0.22)" }}
>
<img
src={block.cover}
alt={block.title}
width={300}
height={450}
loading="lazy"
className="h-full w-full object-cover"
/>
</div>
<div className="flex min-w-0 flex-1 flex-col">
<p className="text-[10px] font-semibold uppercase tracking-[0.25em]" style={{ color: STAMP }}>
Currently writing · Pre-order
</p>
<p
className="mt-1 text-base leading-snug"
style={{ fontFamily: "'Crimson Pro', serif", fontWeight: 600, color: INK }}
>
{block.title}
</p>
{block.blurb && (
<p
className="mt-1 line-clamp-2 text-xs"
style={{ color: "rgba(45,35,28,0.7)" }}
>
{block.blurb}
</p>
)}
<div className="mt-auto flex items-center justify-between pt-2">
{block.price && (
<span className="text-sm font-bold" style={{ color: INK }}>
{block.price}
</span>
)}
<span
className="inline-flex items-center gap-1 text-[12px] font-semibold uppercase tracking-[0.18em]"
style={{ color: STAMP }}
>
Pre-order <ArrowUpRight className="h-3.5 w-3.5" />
</span>
</div>
</div>
</a>
);
}
function FreebieTile({ block }: { block: Extract<RichBlock, { kind: "freebie" }> }) {
const [email, setEmail] = useState("");
const [submitted, setSubmitted] = useState(false);
return (
<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 }}
>
Join the journey
</p>
<p
className="mt-1.5 text-lg leading-snug"
style={{ fontFamily: "'Crimson Pro', serif", fontWeight: 600, color: INK }}
>
{block.title}
</p>
{block.description && (
<p className="mt-1.5 text-sm" style={{ color: "rgba(45,35,28,0.7)" }}>
{block.description}
</p>
)}
{submitted ? (
<div
className="mt-3 flex items-center gap-2 rounded-lg px-3 py-2.5 text-sm"
style={{ background: "rgba(168,90,60,0.10)", color: INK }}
>
<CheckCircle2 className="h-4 w-4" style={{ color: STAMP }} />
<span>Sent — check your inbox.</span>
</div>
) : (
<form
onSubmit={(e) => {
e.preventDefault();
if (email) setSubmitted(true);
}}
className="mt-3 flex flex-col gap-2 sm:flex-row"
>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={block.placeholder ?? "[email protected]"}
className="min-w-0 flex-1 rounded-lg border bg-white/60 px-3 py-2.5 text-sm focus:border-[color:var(--stamp)] focus:outline-none"
style={{ borderColor: RULE, ["--stamp" as never]: STAMP, color: INK }}
/>
<button
type="submit"
className="shrink-0 rounded-lg px-4 py-2.5 text-sm font-semibold"
style={{ background: STAMP, color: PAPER }}
>
{block.cta ?? "Send"}
</button>
</form>
)}
{block.socialProof && !submitted && (
<p
className="mt-2 flex items-center gap-1 text-[11px]"
style={{ color: "rgba(45,35,28,0.55)" }}
>
<Users className="h-3 w-3" /> {block.socialProof}
</p>
)}
</div>
);
}
function LatestPostTile({
block,
}: {
block: Extract<RichBlock, { kind: "latest-post" }>;
}) {
return (
<a
href={block.href}
className="group block overflow-hidden rounded-xl transition-all hover:-translate-y-0.5"
style={{ background: CARD, border: `1px dashed ${RULE}` }}
>
<div className="grid grid-cols-[110px_1fr] gap-3 p-3">
<div
className="overflow-hidden rounded-sm"
style={{ aspectRatio: "1/1" }}
>
<img
src={block.image}
alt=""
width={300}
height={300}
loading="lazy"
className="h-full w-full object-cover"
style={{ filter: "saturate(0.92) contrast(1.02)" }}
/>
</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, color: INK }}
>
{block.title}
</p>
{block.meta && (
<p className="mt-1 text-[11px]" style={{ color: "rgba(45,35,28,0.6)" }}>
{block.meta}
</p>
)}
</div>
</div>
</a>
);
}
export interface LinksInBioPostcardProps {
data?: LinksInBioData;
}
export default function LinksInBioPostcard({
data = defaultData,
}: LinksInBioPostcardProps) {
const heroImage = data.heroImage ?? data.avatar;
const book = data.richBlocks?.find(
(b): b is Extract<RichBlock, { kind: "book" }> => b.kind === "book"
);
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 px-4 pb-12 pt-8"
style={{
background: PAPER,
color: INK,
fontFamily: "'Inter', sans-serif",
backgroundImage: `radial-gradient(${INK}10 1px, transparent 1px)`,
backgroundSize: "16px 16px",
}}
>
<div className="mx-auto w-full max-w-[460px]">
{/* Postcard photo */}
<PostcardFrame image={heroImage} alt={data.name} />
{/* Cursive handle stamp */}
{data.handle && (
<p
className="mt-3 text-center"
style={{
fontFamily: "'Caveat', cursive",
fontSize: "1.6rem",
color: STAMP,
lineHeight: 1.1,
}}
>
{data.handle}
</p>
)}
{/* Name */}
<h1
className="mt-2 text-center"
style={{
fontFamily: "'Crimson Pro', serif",
fontWeight: 700,
fontSize: "clamp(2rem, 6.4vw, 2.6rem)",
letterSpacing: "-0.01em",
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>
)}
<DottedDivider />
{/* Tile mix */}
<div className="space-y-4">
{book && <BookTile block={book} />}
{freebie && <FreebieTile block={freebie} />}
{latestPost && <LatestPostTile block={latestPost} />}
</div>
<DottedDivider />
{/* Stamped link buttons */}
<ul className="space-y-2">
{data.links.slice(0, 4).map((link, i) => {
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>
{/* Socials */}
{data.socials && data.socials.length > 0 && (
<div className="mt-8 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-8 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 postcardWhere to use it
A nostalgic postcard / travel-writer link-in-bio page. Loads Caveat, Crimson Pro, and Inter from Google Fonts. Hardcoded warm cream/sepia palette.
Pass a typed 'data' prop (LinksInBioData from src/registry/links-in-bio/types.ts). Provide:
- heroImage — wide image for the postcard photo (5:4 crop, slight saturation/sepia treatment is applied)
- richBlocks — opt into book / freebie / latest-post tiles
- The stamp corner says 'Lisbon' by default; edit the component to swap it for your own city / postmark
In Astro:
import LinksInBioPostcard from '../components/innovations/links-in-bio/postcard';
<LinksInBioPostcard client:load data={myProfile} />
In Next.js:
import LinksInBioPostcard from '@/components/innovations/links-in-bio/postcard';
Best for: travel writers, lifestyle creators, indie authors, slow-living brands. Use a strong landscape or portrait photo with character — the postcard frame leans into editorial film photography rather than studio-clean shots.