Storyteller
Magazine-style sectioned page — full-bleed photo top with avatar overlap, then numbered sections with typographic headers: Featured (book card with retailers row), Coaching (group program with feature bullets), Free Stuff (freebie email-grab), Find me online (social grid), and More (collapsed link list). Reads like a digital editorial, not a button stack.
Preview
Source
tsx
"use client";
import { useState } from "react";
import {
Instagram,
Twitter,
Youtube,
Mail,
Globe,
ShoppingBag,
PlayCircle,
Linkedin,
Music2,
ArrowRight,
ArrowUpRight,
BookOpen,
Sparkles,
Users,
Check,
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 INK = "#1f1d29";
const PAPER = "#fbf7ee";
const ACCENT = "#7d4adb";
const ACCENT_SOFT = "#ece1ff";
const RULE = "rgba(31,29,41,0.10)";
function SectionHeader({
number,
title,
Icon,
}: {
number: string;
title: string;
Icon: LucideIcon;
}) {
return (
<div className="mb-3 flex items-center gap-3">
<span
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-[10px] font-bold"
style={{ background: ACCENT_SOFT, color: ACCENT }}
>
{number}
</span>
<p
className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-[0.28em]"
style={{ color: ACCENT }}
>
<Icon className="h-3 w-3" />
{title}
</p>
<span aria-hidden className="ml-2 h-px flex-1" style={{ background: RULE }} />
</div>
);
}
function BookSection({ block }: { block: Extract<RichBlock, { kind: "book" }> }) {
return (
<div
className="overflow-hidden rounded-3xl border bg-white"
style={{ borderColor: RULE }}
>
<div className="grid gap-4 p-5 sm:grid-cols-[120px_1fr]">
<div
className="mx-auto overflow-hidden rounded-md sm:mx-0"
style={{
width: "120px",
height: "180px",
boxShadow: "0 24px 40px rgba(31,29,41,0.18)",
}}
>
<img
src={block.cover}
alt={block.title}
width={400}
height={600}
loading="lazy"
className="h-full w-full object-cover"
/>
</div>
<div className="flex flex-col">
<p
className="text-[10px] font-semibold uppercase tracking-[0.22em]"
style={{ color: ACCENT }}
>
New release
</p>
<h3
className="mt-1 text-xl leading-snug"
style={{ fontFamily: "'Fraunces', serif", fontWeight: 700, color: INK }}
>
{block.title}
</h3>
<p className="mt-1 text-xs italic" style={{ color: "rgba(31,29,41,0.6)" }}>
by {block.author}
</p>
{block.blurb && (
<p className="mt-2 text-sm leading-relaxed" style={{ color: "rgba(31,29,41,0.75)" }}>
{block.blurb}
</p>
)}
<div className="mt-3 flex items-center justify-between gap-3">
{block.price && (
<span className="text-base font-bold" style={{ color: INK }}>
{block.price}
</span>
)}
<a
href={block.href}
className="inline-flex items-center gap-1 rounded-full px-4 py-2 text-sm font-semibold"
style={{ background: INK, color: PAPER }}
>
Order now <ArrowRight className="h-3.5 w-3.5" />
</a>
</div>
{block.retailers && block.retailers.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{block.retailers.map((r) => (
<a
key={r.label}
href={r.href}
className="rounded-full border px-3 py-1 text-[11px] font-medium transition-colors hover:bg-[color:var(--soft)]"
style={{
borderColor: RULE,
color: INK,
["--soft" as never]: ACCENT_SOFT,
}}
>
{r.label}
</a>
))}
</div>
)}
</div>
</div>
</div>
);
}
function CoachingSection({
block,
}: {
block: Extract<RichBlock, { kind: "group-program" }>;
}) {
const filled = block.slotsFilled ?? 0;
const total = block.slotsTotal ?? 0;
return (
<div
className="overflow-hidden rounded-3xl border p-5"
style={{ borderColor: RULE, background: ACCENT_SOFT }}
>
<h3
className="text-xl leading-snug"
style={{ fontFamily: "'Fraunces', serif", fontWeight: 700, color: INK }}
>
{block.title}
</h3>
<p className="mt-1 text-xs" style={{ color: "rgba(31,29,41,0.7)" }}>
Cohort opens {block.startDate} · {Math.max(0, total - filled)} of {total} seats left
</p>
{block.features && block.features.length > 0 && (
<ul className="mt-3 grid gap-1.5">
{block.features.map((f) => (
<li key={f} className="flex items-start gap-2 text-sm" style={{ color: INK }}>
<Check className="mt-0.5 h-4 w-4 shrink-0" style={{ color: ACCENT }} />
<span>{f}</span>
</li>
))}
</ul>
)}
<a
href={block.href}
className="mt-4 inline-flex items-center gap-1 rounded-full px-4 py-2 text-sm font-semibold"
style={{ background: ACCENT, color: PAPER }}
>
{block.ctaLabel ?? "Apply"} <ArrowRight className="h-3.5 w-3.5" />
</a>
</div>
);
}
function FreebieSection({ block }: { block: Extract<RichBlock, { kind: "freebie" }> }) {
const [email, setEmail] = useState("");
const [submitted, setSubmitted] = useState(false);
return (
<div
className="overflow-hidden rounded-3xl border bg-white p-5"
style={{ borderColor: RULE }}
>
<p className="text-[10px] font-semibold uppercase tracking-[0.22em]" style={{ color: ACCENT }}>
Free
</p>
<h3
className="mt-1 text-xl leading-snug"
style={{ fontFamily: "'Fraunces', serif", fontWeight: 700, color: INK }}
>
{block.title}
</h3>
{block.description && (
<p className="mt-2 text-sm" style={{ color: "rgba(31,29,41,0.7)" }}>
{block.description}
</p>
)}
{submitted ? (
<div
className="mt-3 flex items-center gap-2 rounded-xl px-3 py-2.5 text-sm"
style={{ background: ACCENT_SOFT, color: INK }}
>
<CheckCircle2 className="h-4 w-4" style={{ color: ACCENT }} />
<span>You're in — 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-full border px-4 py-2.5 text-sm focus:border-[color:var(--accent)] focus:outline-none"
style={{ borderColor: RULE, ["--accent" as never]: ACCENT }}
/>
<button
type="submit"
className="shrink-0 rounded-full px-4 py-2.5 text-sm font-semibold"
style={{ background: ACCENT, color: PAPER }}
>
{block.cta ?? "Send it over"}
</button>
</form>
)}
{block.socialProof && !submitted && (
<p className="mt-2 flex items-center gap-1 text-[11px]" style={{ color: "rgba(31,29,41,0.5)" }}>
<Users className="h-3 w-3" /> {block.socialProof}
</p>
)}
</div>
);
}
export interface LinksInBioStorytellerProps {
data?: LinksInBioData;
}
export default function LinksInBioStoryteller({
data = defaultData,
}: LinksInBioStorytellerProps) {
const heroImage = data.heroImage ?? data.avatar;
const book = data.richBlocks?.find(
(b): b is Extract<RichBlock, { kind: "book" }> => b.kind === "book"
);
const program = data.richBlocks?.find(
(b): b is Extract<RichBlock, { kind: "group-program" }> => b.kind === "group-program"
);
const freebie = data.richBlocks?.find(
(b): b is Extract<RichBlock, { kind: "freebie" }> => b.kind === "freebie"
);
return (
<>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..900;1,9..144,300..900&family=Inter:wght@400;500;600;700&display=swap"
/>
<section
className="min-h-screen w-full pb-14"
style={{ background: PAPER, color: INK, fontFamily: "'Inter', sans-serif" }}
>
{/* Hero */}
<div className="relative h-80 w-full overflow-hidden">
<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(31,29,41,0) 40%, ${PAPER} 100%)`,
}}
/>
</div>
{/* Avatar overlap + identity */}
<div className="relative mx-auto -mt-16 w-full max-w-[520px] px-5">
<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(31,29,41,0.18)",
}}
/>
<h1
className="mt-4 leading-[1.05]"
style={{
fontFamily: "'Fraunces', serif",
fontWeight: 700,
fontSize: "clamp(2.2rem, 6vw, 2.8rem)",
letterSpacing: "-0.01em",
}}
>
{data.name}
{data.verified && (
<span className="ml-2 align-middle text-base" style={{ color: ACCENT }}>
✓
</span>
)}
</h1>
{data.handle && (
<p
className="mt-1 text-sm italic"
style={{ fontFamily: "'Fraunces', serif", color: ACCENT }}
>
{data.handle}
</p>
)}
{data.bio && (
<p
className="mt-3 max-w-[440px] text-[15px] leading-relaxed"
style={{ color: "rgba(31,29,41,0.75)" }}
>
{data.bio}
</p>
)}
</div>
{/* Sections */}
<div className="mt-10 space-y-10">
{book && (
<section>
<SectionHeader number="01" title="Featured" Icon={BookOpen} />
<BookSection block={book} />
</section>
)}
{program && (
<section>
<SectionHeader number="02" title="Coaching" Icon={Users} />
<CoachingSection block={program} />
</section>
)}
{freebie && (
<section>
<SectionHeader number="03" title="Free Stuff" Icon={Sparkles} />
<FreebieSection block={freebie} />
</section>
)}
{/* Find me online */}
{data.socials && data.socials.length > 0 && (
<section>
<SectionHeader number="04" title="Find me online" Icon={Globe} />
<div className="grid grid-cols-3 gap-2 sm:grid-cols-5">
{data.socials.slice(0, 5).map((s, i) => {
const Icon = ICONS[s.type] ?? Globe;
return (
<a
key={i}
href={s.href}
aria-label={s.type}
className="flex flex-col items-center justify-center gap-1.5 rounded-2xl border bg-white py-4 transition-all hover:-translate-y-0.5"
style={{ borderColor: RULE, color: INK }}
>
<Icon className="h-5 w-5" strokeWidth={1.6} />
<span className="text-[10px] font-semibold uppercase tracking-widest capitalize">
{s.type}
</span>
</a>
);
})}
</div>
</section>
)}
{/* More */}
<section>
<SectionHeader number="05" title="More" Icon={ArrowUpRight} />
<ul className="overflow-hidden rounded-3xl border bg-white" style={{ borderColor: RULE }}>
{data.links.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 px-4 py-3.5 transition-colors hover:bg-[color:var(--soft)]"
style={{
borderTop: i === 0 ? "none" : `1px solid ${RULE}`,
["--soft" as never]: ACCENT_SOFT,
color: INK,
}}
>
<Icon className="h-4 w-4 shrink-0" strokeWidth={1.6} />
<span className="flex-1 truncate text-sm">{link.label}</span>
{link.badge && (
<span
className="rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider"
style={{ background: ACCENT, color: PAPER }}
>
{link.badge}
</span>
)}
<ArrowUpRight className="h-4 w-4 shrink-0 opacity-40" />
</a>
</li>
);
})}
</ul>
</section>
</div>
<p
className="mt-10 text-center text-[10px] uppercase tracking-[0.3em]"
style={{ color: "rgba(31,29,41,0.45)" }}
>
A small magazine of internet things
</p>
</div>
</section>
</>
);
} Claude Code Instructions
CLI Install
npx innovations add storytellerWhere to use it
A multi-offer link-in-bio page that reads like a small magazine — each offer gets its own section with a numbered header.
Pass a typed 'data' prop (LinksInBioData from src/registry/links-in-bio/types.ts). Provide:
- heroImage — wide image for the top hero (falls back to avatar)
- richBlocks — opt into book / group-program / freebie sections by adding entries with those kinds. Sections only render when their block exists.
- book.retailers — optional array of { label, href } pills shown beneath the book CTA
In Astro:
import LinksInBioStoryteller from '../components/innovations/links-in-bio/storyteller';
<LinksInBioStoryteller client:load data={myProfile} />
In Next.js:
import LinksInBioStoryteller from '@/components/innovations/links-in-bio/storyteller';
Best for: authors, coaches, thought leaders selling a book + cohort + lead magnet stack. The number/section structure (01 / 02 / 03 / 04 / 05) keeps the offer hierarchy obvious.