Calm Founder Landing Page with Karaoke Audio Narration

A prompt for building a calm, editorial single-page founder site with synchronised audio narration that highlights words as they're spoken.

  • landing page
  • react
  • tailwind
  • audio
PROMPT — Calm founder landing page with karaoke audio narration

Build a single-page React + TypeScript + Vite + Tailwind site. The aesthetic is calm and editorial: generous whitespace, Inter typography, no dividers, no drop shadows, no heavy borders. Everything must use semantic HSL design tokens — never raw color classes.

Stack
- React 18, TypeScript, Vite
- Tailwind CSS v3 + shadcn/ui
- React Router v6
- lucide-react icons
- Inter font from Google Fonts (weights 400/500/600/700)

Design tokens (src/index.css)
Define on :root (light) and .dark:

Light:
--background: 0 0% 100%;
--foreground: 220 30% 18%;
--primary: 220 30% 18%;
--primary-foreground: 0 0% 100%;
--muted: 220 15% 96%;
--muted-foreground: 220 10% 50%;
--border: 220 15% 90%;
--ring: 220 30% 18%;
--radius: 0.25rem;
--text-body: 220 15% 30%;
--text-subtle: 220 10% 55%;
--link-hover: 220 60% 45%;

Dark:
--background: 220 30% 8%;
--foreground: 220 15% 95%;
--muted: 220 20% 15%;
--border: 220 20% 18%;
--text-body: 220 10% 75%;
--text-subtle: 220 10% 55%;
--link-hover: 220 60% 65%;

Body uses Inter. Add helper classes:
- .text-body → text-[hsl(var(--text-body))]
- .text-subtle → text-[hsl(var(--text-subtle))]
- .link-subtle → text-foreground font-bold transition-colors duration-200 hover:text-[hsl(var(--link-hover))]

Expose body and subtle color aliases in tailwind.config.ts so text-body / text-subtle work as Tailwind utilities.

Layout (/ page)
Centered column, max-w-xl mx-auto px-6 py-12 md:py-24:

1. 64px square logo, rounded-xl, top-left.
2. <NarrationPlayer> (see below) wrapping the founder bio (4 short paragraphs in first person, ending with "— Alex"). The first paragraph contains an inline link to the App Store with a small ExternalLink icon (lucide).
3. A row of social links.
4. A subtle location line: lucide Coffee icon + a short phrase, in text-xs text-subtle.
5. A faded full-width wordmark image at opacity-[0.04], pointer-events-none select-none, as a quiet background flourish near the bottom.
6. Footer: © 2026 [Brand] left; Privacy / Terms right; all text-xs text-subtle, hover transitions to foreground.

Also add /privacy, /terms, and a 404 route. Mount a ScrollToTop helper inside the router.

NarrationPlayer component (the key piece)
File: src/components/NarrationPlayer.tsx. Behavior:

- Renders a hidden <audio preload="metadata" src="/audio/founder-narration.m4a" />. Place a ~30–60s narration MP4/M4A in public/audio/.
- Renders a title row: an <h1 class="text-lg text-foreground"> with the speaker name, plus a 28px circular play/pause button (bg-muted/50 hover:bg-muted, rounded-full) using lucide Volume2 (idle/paused) and Pause (playing) icons at w-3.5 h-3.5.
- Renders children inside <article class="mb-10 narration-text [&_p]:mb-6">. Adds class narration-active to the article whenever audio is playing or paused mid-track.

Karaoke logic:

1. On mount, recursively walk the article DOM. For every text node, split on /(\s+)/ and wrap each non-whitespace token in <span class="narration-word" data-word>. Preserve whitespace text nodes and skip nodes that are already wrapped. Inline elements (like the App Store link) must be traversed into, not replaced.
2. Count totalWords = querySelectorAll('[data-word]').length.
3. While playing, run a requestAnimationFrame loop. Each frame:
   const PACE_MULTIPLIER = 0.90; // compensates for natural pauses
   const progress = audio.currentTime / audio.duration;
   const adjusted = Math.min(progress * PACE_MULTIPLIER, 1);
   const highlightIndex = Math.floor(adjusted * totalWords);
4. When highlightIndex changes, iterate every word span:
   - clear is-active and is-upcoming
   - if index < highlightIndex → add is-active
   - else if index < highlightIndex + 6 → add is-upcoming
5. Audio events: play → start RAF + set state playing; pause → cancel RAF; ended → cancel RAF, reset highlightIndex = -1. Click handler: idle/paused → play(); playing → pause(); ended → reset currentTime = 0 and play().
6. If window.matchMedia('(prefers-reduced-motion: reduce)').matches, also add reduced-motion to each word span.

Karaoke CSS (append to src/index.css):

.narration-text .narration-word {
  opacity: 1;
  transition: opacity 250ms linear, color 250ms linear, font-weight 250ms linear;
}
.narration-text.narration-active .narration-word { opacity: 0.35; }
.narration-text.narration-active .narration-word.is-upcoming { opacity: 0.55; }
.narration-text.narration-active .narration-word.is-active {
  opacity: 1;
  color: hsl(var(--foreground));
  font-weight: 500;
}
.narration-word.reduced-motion { transition: none !important; }
.narration-button:focus-visible {
  outline: 2px solid hsl(var(--ring));
  outline-offset: 2px;
}

Effect: when the user presses play, every word fades to 35% opacity, the words "spoken so far" snap to full foreground color and weight 500, and the next ~6 words sit at 55% opacity as a soft look-ahead. When idle or ended, everything returns to its natural readable state.

SEO / meta (index.html)
- Title <60 chars, meta description <160 chars
- Open Graph + Twitter card tags, canonical link, responsive viewport
- Optional Apple smart app banner meta if linking to an iOS app
- JSON-LD SoftwareApplication block if applicable

Hard rules
- Only semantic tokens — no text-white, bg-black, hex, or RGB anywhere in components.
- All colors stored as raw HSL triplets in index.css, consumed via hsl(var(--token)).
- Never autoplay audio. Playback only on user click.
- No borders/dividers between sections; rely on spacing for rhythm.