Innovations

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 ?? "you@somewhere.com"}
            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 storyteller

Where 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.