● Zion Boggan zionboggan.com ↗

Redesign UI with bespoke ON BEAT design system

670f915   Zion Boggan committed on Jun 25, 2026 (1 month ago)
DESIGN_SYSTEM.md +86 -0
@@ -0,0 +1,86 @@
+# Stepping: Design System - "ON BEAT"
+
+A bespoke visual system for the Divine Nine stepping app. Built from first
+principles, grounded in step-show culture. It deliberately does NOT use the
+`ui-ux-pro-max` skill or any generic component-kit look. Every choice is
+sourced from (a) Zion's reference portfolios and (b) license-clean open-source
+technique, then bent toward the culture.
+
+## Sourcing (provenance, so this stays defensible and original)
+
+- **Design DNA** distilled from Zion's references: monolithstudio.com,
+ airborne.studio, donprod.uk, ning-h.com, tenbinlabs.xyz, wildyriftian.com,
+ oscarpecher.com. Common laws we adopt: two-tone discipline, warm-black /
+ warm-paper (never pure #000/#fff), type-as-art, mono micro-type for metadata,
+ one signature interactive object, velocity-driven motion, custom cursor as a
+ discovery mechanic, a named concept spine, negative space as luxury.
+- **Technique** lifted only from MIT/CC0 repos (security-scanned, semgrep clean):
+ `studio-freight/lenis` (MIT) for smooth scroll; structural patterns from
+ `brunosimon/folio-2019` and `ektogamat/threejs-andy-boilerplate` (MIT). The
+ `olivierlarose` staggered-reveal / zoom-parallax repos are no-license, so we
+ REIMPLEMENT their technique from scratch, never copy their files.
+
+## Concept spine: ON BEAT
+
+The site runs like a step show that never stops. Stepping is body-as-instrument:
+stomps, claps, spoken word, synchronized lines. So the interface keeps time. The
+page is a show with a run of acts:
+
+1. **STAGE** - the hero. Blackout, spotlight, the name hits on a beat.
+2. **THE LINE-UP** - events (step shows, strolls, competitions).
+3. **THE YARD** - community: chapters, step teams, DJs, promoters, classes.
+4. **THE ROUTE** - per-city travel for traveling to a show.
+
+## Color - two-tone + one heritage accent (warm)
+
+| Token | Hex | Role |
+|---|---|---|
+| `--stage` | `#0F0E0C` | ground (warm near-black, a stage blackout / brick at night) |
+| `--bone` | `#F2ECE0` | ink (warm spotlight bone, never pure white) |
+| `--gold` | `#C8A24C` | the only accent: heritage prestige gold, neutral across all nine orgs |
+| `--gold-bright` | `#E4C572` | gold hover / active |
+| `--ash` | `#8A8275` | warm grey for secondary mono metadata |
+| `--line` | `rgba(242,236,224,0.14)` | hairlines |
+
+Discipline: no other hues. We never adopt a single fraternity/sorority's colors
+(the app serves all nine); gold is the shared dignifier.
+
+## Type - percussive + editorial duality (call-and-response)
+
+- **Display / impact:** `Bricolage Grotesque` - oversized, tight, declarative.
+ The "stomp." Used huge for act titles and the name.
+- **Editorial accent:** `Instrument Serif` - high-contrast serif for the
+ storytelling lines (the definition of stepping is literally storytelling).
+- **Metadata:** `Space Mono` - every label, index, year, set time, and city
+ coordinate. Maps the "art-tier mono micro-type" law onto real step data:
+ `EST. 1906`, `ΑΦΑ`, `ATL · 33.749, -84.388`, `01 / 06`.
+
+Type rule: on any screen, either the display type is loud or the imagery is.
+Never both.
+
+## Motion - "on beat"
+
+- **Cadence:** reveals are staggered like a stomp-clap, not a uniform fade.
+ Short, snappy, slightly syncopated timing.
+- **Velocity-reactive:** display type reacts to scroll speed (skew/weight),
+ reimplemented with Lenis + transform mapping (not copied).
+- **Smooth scroll:** Lenis (MIT).
+- **Signature object / cursor:** a custom cursor that leaves a clap-ripple on
+ click and a soft trailing dot - percussion you can feel. This is our "one
+ signature interactive object," lightweight (canvas/DOM), no heavy WebGL.
+- **Reduced motion:** all of the above gate on `prefers-reduced-motion`.
+
+## Layout
+
+Editorial and asymmetric. Generous negative space. Numbered/lettered systems
+everywhere (founding years, chapter designations, `01/06` indices). Hairline
+rules and mono kickers frame each act. Mobile-first; the show reflows to a single
+column without losing the beat.
+
+## Components (first set)
+
+- `BeatCursor` - custom cursor + clap-ripple on click.
+- `SmoothScroll` - Lenis provider.
+- `Marquee` - a running mono ticker of founding years / city names (the "roll").
+- Act section shell - mono kicker + huge display title + content.
+- Event card, profile card - bone-on-stage, gold for the one thing that matters.
apps/web/package.json +1 -0
@@ -20,6 +20,7 @@
"@trpc/client": "^11.0.0",
"@trpc/react-query": "^11.0.0",
"@trpc/server": "^11.0.0",
+ "lenis": "^1.3.24",
"next": "16.2.9",
"react": "19.2.4",
"react-dom": "19.2.4",
apps/web/src/app/city/[slug]/page.tsx +61 -52
@@ -15,15 +15,17 @@ const TYPE_LABEL: Record<string, string> = {
special: "Special",
};
-function fmtDate(d: Date | null) {
- if (!d) return "Date TBD";
- return new Date(d).toLocaleString("en-US", {
- weekday: "short",
- month: "short",
- day: "numeric",
- hour: "numeric",
- minute: "2-digit",
- });
+function fmt(d: Date | null) {
+ if (!d) return "DATE TBD";
+ return new Date(d)
+ .toLocaleString("en-US", {
+ weekday: "short",
+ month: "short",
+ day: "numeric",
+ hour: "numeric",
+ minute: "2-digit",
+ })
+ .toUpperCase();
}
export default async function CityPage({
@@ -54,76 +56,83 @@ export default async function CityPage({
}
return (
- <main className="mx-auto w-full max-w-2xl flex-1 px-6 py-12">
- <Link
- href="/"
- className="text-sm text-zinc-500 hover:text-zinc-800 dark:hover:text-zinc-200"
- >
- &larr; All cities
+ <main className="mx-auto w-full max-w-3xl px-5 py-16 sm:px-8">
+ <Link href="/" className="kicker hover:text-gold">
+ ← Stepping
</Link>
- <h1 className="mt-3 text-3xl font-semibold tracking-tight text-black dark:text-zinc-50">
- {city.name}
- </h1>
- <p className="text-zinc-500 dark:text-zinc-400">{city.metroLabel}</p>
+ <p className="kicker mt-10 text-ash">
+ {city.metroLabel} · {city.lat?.toFixed(3)}, {city.lng?.toFixed(3)}
+ </p>
+ <h1 className="display mt-3 text-7xl sm:text-8xl">{city.name}</h1>
- <section className="mt-8">
- <h2 className="text-sm font-medium uppercase tracking-widest text-amber-600">
- Add an event
- </h2>
- <form action={addEvent} className="mt-3 flex flex-col gap-3">
+ {/* Add an event */}
+ <section className="mt-14 border-t border-[var(--line)] pt-10">
+ <div className="flex items-end justify-between">
+ <h2 className="display text-3xl">Post a show</h2>
+ <span className="kicker">Add to the line-up</span>
+ </div>
+ <form action={addEvent} className="mt-6 flex flex-col gap-3">
<input
name="title"
required
placeholder="Event name (e.g. Homecoming Step Show)"
- className="rounded-lg border border-zinc-300 bg-white px-3 py-2 text-black dark:border-zinc-700 dark:bg-zinc-950 dark:text-white"
+ className="serif border-b border-[var(--line)] bg-transparent py-3 text-2xl text-bone outline-none placeholder:text-ash focus:border-gold"
/>
- <div className="flex gap-3">
+ <div className="mt-2 flex flex-wrap gap-4">
<select
name="type"
- className="rounded-lg border border-zinc-300 bg-white px-3 py-2 text-black dark:border-zinc-700 dark:bg-zinc-950 dark:text-white"
+ className="kicker border border-[var(--line)] bg-stage px-4 py-3 text-bone outline-none focus:border-gold"
>
{EVENT_TYPE.map((t) => (
- <option key={t} value={t}>
- {TYPE_LABEL[t] ?? t}
+ <option key={t} value={t} className="bg-stage">
+ {(TYPE_LABEL[t] ?? t).toUpperCase()}
</option>
))}
</select>
<input
type="datetime-local"
name="startsAt"
- className="rounded-lg border border-zinc-300 bg-white px-3 py-2 text-black dark:border-zinc-700 dark:bg-zinc-950 dark:text-white"
+ className="kicker border border-[var(--line)] bg-stage px-4 py-3 text-bone outline-none focus:border-gold"
/>
+ <button
+ type="submit"
+ className="kicker bg-gold px-6 py-3 text-stage hover:bg-gold-bright"
+ >
+ Post event
+ </button>
</div>
- <button
- type="submit"
- className="self-start rounded-full bg-amber-600 px-5 py-2 font-medium text-white hover:bg-amber-700"
- >
- Post event
- </button>
</form>
</section>
- <section className="mt-10">
- <h2 className="text-sm font-medium uppercase tracking-widest text-zinc-400">
- Events
- </h2>
+ {/* The line-up */}
+ <section className="mt-16 border-t border-[var(--line)] pt-10">
+ <div className="flex items-end justify-between">
+ <h2 className="display text-3xl">The Line-Up</h2>
+ <span className="kicker text-ash">
+ {String(events.length).padStart(2, "0")} shows
+ </span>
+ </div>
{events.length === 0 ? (
- <p className="mt-3 text-zinc-500">
- No events yet. Add the first one above.
+ <p className="serif mt-8 text-xl text-ash">
+ No shows yet. Post the first one above.
</p>
) : (
- <ul className="mt-3 divide-y divide-zinc-200 dark:divide-zinc-800">
- {events.map((e) => (
- <li key={e.id} className="flex items-center justify-between py-4">
- <div>
- <p className="font-medium text-black dark:text-zinc-50">
- {e.title}
- </p>
- <p className="text-sm text-zinc-500">{fmtDate(e.startsAt)}</p>
+ <ul className="mt-6">
+ {events.map((e, i) => (
+ <li
+ key={e.id}
+ className="flex items-center gap-5 border-b border-[var(--line)] py-6"
+ >
+ <span className="kicker w-8 text-gold">
+ {String(i + 1).padStart(2, "0")}
+ </span>
+ <div className="flex-1">
+ <p className="display text-2xl sm:text-3xl">{e.title}</p>
+ <p className="kicker mt-1 text-ash">{fmt(e.startsAt)}</p>
</div>
- <span className="rounded-full bg-zinc-100 px-3 py-1 text-xs font-medium text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300">
- {TYPE_LABEL[e.type] ?? e.type}
+ <span className="kicker border border-[var(--line)] px-3 py-1">
+ {(TYPE_LABEL[e.type] ?? e.type).toUpperCase()}
</span>
</li>
))}
apps/web/src/app/globals.css +137 -14
@@ -1,26 +1,149 @@
@import "tailwindcss";
:root {
- --background: #ffffff;
- --foreground: #171717;
+ --stage: #0f0e0c;
+ --bone: #f2ece0;
+ --gold: #c8a24c;
+ --gold-bright: #e4c572;
+ --ash: #8a8275;
+ --line: rgba(242, 236, 224, 0.14);
}
@theme inline {
- --color-background: var(--background);
- --color-foreground: var(--foreground);
- --font-sans: var(--font-geist-sans);
- --font-mono: var(--font-geist-mono);
+ --color-stage: var(--stage);
+ --color-bone: var(--bone);
+ --color-gold: var(--gold);
+ --color-gold-bright: var(--gold-bright);
+ --color-ash: var(--ash);
+ --font-display: var(--font-bricolage);
+ --font-serif: var(--font-instrument);
+ --font-mono: var(--font-space-mono);
}
-@media (prefers-color-scheme: dark) {
- :root {
- --background: #0a0a0a;
- --foreground: #ededed;
- }
+* {
+ box-sizing: border-box;
+}
+
+html {
+ -webkit-font-smoothing: antialiased;
+ text-rendering: optimizeLegibility;
}
body {
- background: var(--background);
- color: var(--foreground);
- font-family: Arial, Helvetica, sans-serif;
+ background: var(--stage);
+ color: var(--bone);
+ font-family: var(--font-bricolage), system-ui, sans-serif;
+ overflow-x: hidden;
+}
+
+::selection {
+ background: var(--gold);
+ color: var(--stage);
+}
+
+@media (pointer: fine) {
+ .beat-cursor-on,
+ .beat-cursor-on a,
+ .beat-cursor-on button,
+ .beat-cursor-on * {
+ cursor: none;
+ }
+}
+
+.kicker {
+ font-family: var(--font-space-mono), monospace;
+ font-size: 0.72rem;
+ letter-spacing: 0.28em;
+ text-transform: uppercase;
+ color: var(--ash);
+}
+
+.display {
+ font-family: var(--font-bricolage), sans-serif;
+ font-weight: 800;
+ line-height: 0.86;
+ letter-spacing: -0.03em;
+}
+
+.serif {
+ font-family: var(--font-instrument), Georgia, serif;
+}
+
+.beat-dot {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 11px;
+ height: 11px;
+ border-radius: 9999px;
+ background: var(--gold);
+ pointer-events: none;
+ z-index: 60;
+ will-change: transform;
+}
+
+.clap-ripple {
+ position: fixed;
+ width: 10px;
+ height: 10px;
+ border: 1.5px solid var(--gold);
+ border-radius: 9999px;
+ transform: translate(-50%, -50%);
+ pointer-events: none;
+ z-index: 59;
+ animation: clap 0.6s ease-out forwards;
+}
+
+@keyframes clap {
+ 0% {
+ opacity: 0.9;
+ width: 10px;
+ height: 10px;
+ }
+ 100% {
+ opacity: 0;
+ width: 96px;
+ height: 96px;
+ }
+}
+
+@keyframes marquee {
+ from {
+ transform: translateX(0);
+ }
+ to {
+ transform: translateX(-50%);
+ }
+}
+
+.marquee-track {
+ animation: marquee 38s linear infinite;
+}
+
+@keyframes beatIn {
+ 0% {
+ opacity: 0;
+ transform: translateY(20px);
+ }
+ 100% {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.beat-in {
+ animation: beatIn 0.55s cubic-bezier(0.2, 0.85, 0.2, 1) both;
+}
+
+.b1 { animation-delay: 0.02s; }
+.b2 { animation-delay: 0.1s; }
+.b3 { animation-delay: 0.26s; }
+.b4 { animation-delay: 0.34s; }
+.b5 { animation-delay: 0.46s; }
+
+@media (prefers-reduced-motion: reduce) {
+ .marquee-track,
+ .beat-in {
+ animation: none;
+ }
}
apps/web/src/app/layout.tsx +26 -10
@@ -1,22 +1,35 @@
import type { Metadata } from "next";
-import { Geist, Geist_Mono } from "next/font/google";
+import { Bricolage_Grotesque, Instrument_Serif, Space_Mono } from "next/font/google";
import "./globals.css";
import { TRPCProvider } from "@/lib/trpc/provider";
+import { SmoothScroll } from "@/components/smooth-scroll";
+import { BeatCursor } from "@/components/beat-cursor";
-const geistSans = Geist({
- variable: "--font-geist-sans",
+const bricolage = Bricolage_Grotesque({
subsets: ["latin"],
+ variable: "--font-bricolage",
+ display: "swap",
});
-const geistMono = Geist_Mono({
- variable: "--font-geist-mono",
+const instrument = Instrument_Serif({
subsets: ["latin"],
+ weight: "400",
+ style: ["normal", "italic"],
+ variable: "--font-instrument",
+ display: "swap",
+});
+
+const spaceMono = Space_Mono({
+ subsets: ["latin"],
+ weight: ["400", "700"],
+ variable: "--font-space-mono",
+ display: "swap",
});
export const metadata: Metadata = {
- title: "Stepping",
+ title: "Stepping - On Beat",
description:
- "Discover step shows, strolls, classes, DJs and the culture around them, city by city.",
+ "The home for Divine Nine step show culture. Step shows, strolls, classes, DJs and the people, city by city.",
};
export default function RootLayout({
@@ -27,10 +40,13 @@ export default function RootLayout({
return (
<html
lang="en"
- className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
+ className={`${bricolage.variable} ${instrument.variable} ${spaceMono.variable} beat-cursor-on h-full`}
>
- <body className="min-h-full flex flex-col">
- <TRPCProvider>{children}</TRPCProvider>
+ <body className="min-h-full bg-stage text-bone">
+ <BeatCursor />
+ <SmoothScroll>
+ <TRPCProvider>{children}</TRPCProvider>
+ </SmoothScroll>
</body>
</html>
);
apps/web/src/app/page.tsx +176 -45
@@ -1,62 +1,193 @@
import Link from "next/link";
+import { serverCaller } from "@/lib/trpc/server";
+import { Marquee } from "@/components/marquee";
-const LAUNCH_CITIES = [
- { slug: "atlanta", name: "Atlanta" },
- { slug: "washington-dc", name: "Washington, DC" },
-];
+export const dynamic = "force-dynamic";
+
+const NINE = ["ΑΦΑ", "ΑΚΑ", "ΚΑΨ", "ΩΨΦ", "ΔΣΘ", "ΦΒΣ", "ΖΦΒ", "ΣΓΡ", "ΙΦΘ"];
-const FEATURES = [
- "Step shows, strolls, and competitions near you",
- "Classes, DJs, and promoters in each city",
- "Watch and live-stream performances",
- "Chapters and step teams across the Divine Nine",
- "Where to stay, eat, and go when you travel for a show",
+const YARD = [
+ { k: "Chapters", d: "Fraternity and sorority chapters in each city." },
+ { k: "Step teams", d: "The crews putting in work for the next show." },
+ { k: "DJs", d: "Who is spinning the set, and where." },
+ { k: "Promoters", d: "The people who throw the shows and strolls." },
+ { k: "Classes", d: "Learn the craft. Stomp, clap, spoken word." },
];
-export default function Home() {
+function fmt(d: Date | null) {
+ if (!d) return "DATE TBD";
+ return new Date(d)
+ .toLocaleString("en-US", {
+ month: "short",
+ day: "numeric",
+ hour: "numeric",
+ minute: "2-digit",
+ })
+ .toUpperCase();
+}
+
+export default async function Home() {
+ const caller = serverCaller();
+ const cities = await caller.city.listLaunched();
+ const byCity = new Map(cities.map((c) => [c.id, c]));
+ const lists = await Promise.all(
+ cities.map((c) => caller.event.list({ cityId: c.id })),
+ );
+ const lineup = lists
+ .flat()
+ .sort(
+ (a, b) =>
+ (a.startsAt ? +new Date(a.startsAt) : Infinity) -
+ (b.startsAt ? +new Date(b.startsAt) : Infinity),
+ )
+ .slice(0, 6);
+
return (
- <main className="flex flex-1 flex-col items-center justify-center bg-zinc-50 px-6 py-24 dark:bg-black">
- <div className="w-full max-w-2xl">
- <p className="text-sm font-medium uppercase tracking-widest text-amber-600">
- Divine Nine stepping
- </p>
- <h1 className="mt-3 text-4xl font-semibold tracking-tight text-black sm:text-5xl dark:text-zinc-50">
- Stepping
+ <div className="relative">
+ <header className="fixed top-0 z-50 flex w-full items-center justify-between px-5 py-4 sm:px-8">
+ <Link href="/" className="display text-lg tracking-tight text-bone">
+ STEPPING
+ </Link>
+ <nav className="hidden gap-7 sm:flex">
+ <a href="#lineup" className="kicker hover:text-bone">
+ The Line-Up
+ </a>
+ <a href="#yard" className="kicker hover:text-bone">
+ The Yard
+ </a>
+ <a href="#route" className="kicker hover:text-bone">
+ The Route
+ </a>
+ </nav>
+ </header>
+
+ {/* STAGE */}
+ <section className="flex min-h-[100svh] flex-col justify-center px-5 pt-24 sm:px-8">
+ <p className="kicker beat-in b1">On Beat / Divine Nine Stepping</p>
+ <h1 className="display mt-6 text-[20vw] leading-[0.82] sm:text-[16vw]">
+ <span className="block beat-in b1">STOMP.</span>
+ <span className="block text-gold beat-in b2">CLAP.</span>
+ <span className="serif block italic font-normal beat-in b3">speak.</span>
</h1>
- <p className="mt-4 text-lg leading-8 text-zinc-600 dark:text-zinc-400">
- The home for step show culture. Find the shows, the teams, the DJs, and
- the people, then plan the trip around them.
+ <p className="serif mt-8 max-w-xl text-2xl leading-snug text-bone/80 beat-in b4">
+ The home for step show culture. The body is the instrument. Find the
+ shows, the lines, the people, and the road to every set.
</p>
-
- <ul className="mt-8 space-y-3">
- {FEATURES.map((f) => (
- <li
- key={f}
- className="flex items-start gap-3 text-zinc-700 dark:text-zinc-300"
+ <div className="mt-10 flex flex-wrap items-center gap-x-6 gap-y-2 beat-in b5">
+ {cities.map((c) => (
+ <Link
+ key={c.id}
+ href={`/city/${c.slug}`}
+ className="kicker text-bone hover:text-gold"
>
- <span className="mt-2 h-1.5 w-1.5 shrink-0 rounded-full bg-amber-600" />
- {f}
- </li>
+ {c.name} · {c.lat?.toFixed(3)}, {c.lng?.toFixed(3)}
+ </Link>
))}
- </ul>
+ <span className="kicker text-ash">More cities loading</span>
+ </div>
+ </section>
+
+ <Marquee />
+
+ {/* THE NINE */}
+ <section className="flex flex-wrap items-baseline justify-between gap-4 px-5 py-10 sm:px-8">
+ {NINE.map((g) => (
+ <span key={g} className="display text-3xl text-bone/70 sm:text-4xl">
+ {g}
+ </span>
+ ))}
+ </section>
- <div className="mt-10 rounded-xl border border-zinc-200 bg-white p-5 dark:border-zinc-800 dark:bg-zinc-950">
- <p className="text-sm font-medium text-zinc-500 dark:text-zinc-400">
- Launching first in
- </p>
- <div className="mt-2 flex flex-wrap gap-2">
- {LAUNCH_CITIES.map((c) => (
+ {/* THE LINE-UP */}
+ <section
+ id="lineup"
+ className="border-t border-[var(--line)] px-5 py-16 sm:px-8"
+ >
+ <div className="flex items-end justify-between">
+ <h2 className="display text-5xl sm:text-7xl">The Line-Up</h2>
+ <span className="kicker">01 / Shows</span>
+ </div>
+ <ul className="mt-10">
+ {lineup.length === 0 && (
+ <li className="kicker text-ash">No shows posted yet.</li>
+ )}
+ {lineup.map((e, i) => (
+ <li key={e.id}>
<Link
- key={c.slug}
- href={`/city/${c.slug}`}
- className="rounded-full bg-amber-100 px-3 py-1 text-sm font-medium text-amber-900 hover:bg-amber-200 dark:bg-amber-950 dark:text-amber-200 dark:hover:bg-amber-900"
+ href={`/city/${byCity.get(e.cityId)?.slug ?? ""}`}
+ className="group flex items-center gap-5 border-b border-[var(--line)] py-6"
>
- {c.name}
+ <span className="kicker w-10 text-gold">
+ {String(i + 1).padStart(2, "0")}
+ </span>
+ <span className="display flex-1 text-2xl text-bone group-hover:text-gold sm:text-4xl">
+ {e.title}
+ </span>
+ <span className="kicker hidden sm:block">
+ {byCity.get(e.cityId)?.name}
+ </span>
+ <span className="kicker w-28 text-right text-ash">
+ {fmt(e.startsAt)}
+ </span>
</Link>
- ))}
- </div>
+ </li>
+ ))}
+ </ul>
+ </section>
+
+ {/* THE YARD */}
+ <section
+ id="yard"
+ className="border-t border-[var(--line)] px-5 py-16 sm:px-8"
+ >
+ <div className="flex items-end justify-between">
+ <h2 className="display text-5xl sm:text-7xl">The Yard</h2>
+ <span className="kicker">02 / Community</span>
+ </div>
+ <div className="mt-10 grid gap-px sm:grid-cols-2 lg:grid-cols-3">
+ {YARD.map((y) => (
+ <div key={y.k} className="py-6 pr-6">
+ <h3 className="display text-3xl">{y.k}</h3>
+ <p className="serif mt-2 text-lg text-bone/70">{y.d}</p>
+ </div>
+ ))}
</div>
- </div>
- </main>
+ </section>
+
+ {/* THE ROUTE */}
+ <section
+ id="route"
+ className="border-t border-[var(--line)] px-5 py-16 sm:px-8"
+ >
+ <div className="flex items-end justify-between">
+ <h2 className="display text-5xl sm:text-7xl">The Route</h2>
+ <span className="kicker">03 / Cities</span>
+ </div>
+ <div className="mt-10 grid gap-px sm:grid-cols-2">
+ {cities.map((c) => (
+ <Link
+ key={c.id}
+ href={`/city/${c.slug}`}
+ className="group flex items-end justify-between border-t border-[var(--line)] py-10"
+ >
+ <div>
+ <span className="kicker text-ash">{c.metroLabel}</span>
+ <h3 className="display mt-2 text-5xl group-hover:text-gold sm:text-6xl">
+ {c.name}
+ </h3>
+ </div>
+ <span className="kicker text-gold">Enter →</span>
+ </Link>
+ ))}
+ </div>
+ </section>
+
+ <footer className="flex flex-wrap items-center justify-between gap-3 border-t border-[var(--line)] px-5 py-8 sm:px-8">
+ <span className="display text-xl">STEPPING</span>
+ <span className="kicker text-ash">
+ On Beat · Built for the culture
+ </span>
+ </footer>
+ </div>
);
}
apps/web/src/components/beat-cursor.tsx +57 -0
@@ -0,0 +1,57 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+
+// Our one signature interactive object: a gold cursor dot that trails the
+// pointer and leaves a clap-ripple on click. Percussion you can feel.
+// Disabled on touch and when reduced motion is requested.
+export function BeatCursor() {
+ const dot = useRef<HTMLDivElement>(null);
+ const [enabled, setEnabled] = useState(false);
+
+ useEffect(() => {
+ const fine = window.matchMedia("(pointer: fine)").matches;
+ const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+ if (!fine || reduce) return;
+ setEnabled(true);
+
+ let x = window.innerWidth / 2;
+ let y = window.innerHeight / 2;
+ let tx = x;
+ let ty = y;
+ let raf = 0;
+
+ const loop = () => {
+ x += (tx - x) * 0.2;
+ y += (ty - y) * 0.2;
+ const d = dot.current;
+ if (d) d.style.transform = `translate(${x}px, ${y}px) translate(-50%, -50%)`;
+ raf = requestAnimationFrame(loop);
+ };
+ raf = requestAnimationFrame(loop);
+
+ const move = (e: MouseEvent) => {
+ tx = e.clientX;
+ ty = e.clientY;
+ };
+ const clap = (e: MouseEvent) => {
+ const r = document.createElement("span");
+ r.className = "clap-ripple";
+ r.style.left = `${e.clientX}px`;
+ r.style.top = `${e.clientY}px`;
+ document.body.appendChild(r);
+ window.setTimeout(() => r.remove(), 600);
+ };
+
+ window.addEventListener("mousemove", move);
+ window.addEventListener("click", clap);
+ return () => {
+ cancelAnimationFrame(raf);
+ window.removeEventListener("mousemove", move);
+ window.removeEventListener("click", clap);
+ };
+ }, []);
+
+ if (!enabled) return null;
+ return <div ref={dot} className="beat-dot" aria-hidden="true" />;
+}
apps/web/src/components/marquee.tsx +34 -0
@@ -0,0 +1,34 @@
+// A running mono ticker (the "roll"): founding years and launch cities.
+const ROLL = [
+ "EST. 1906",
+ "ATLANTA",
+ "EST. 1908",
+ "WASHINGTON DC",
+ "EST. 1911",
+ "STEP SHOWS",
+ "EST. 1913",
+ "STROLLS",
+ "EST. 1914",
+ "THE YARD",
+ "EST. 1920",
+ "ON BEAT",
+ "EST. 1922",
+ "DIVINE NINE",
+ "EST. 1963",
+];
+
+export function Marquee() {
+ const items = [...ROLL, ...ROLL];
+ return (
+ <div className="overflow-hidden border-y border-[var(--line)] py-3">
+ <div className="marquee-track flex w-max gap-8 whitespace-nowrap">
+ {items.map((t, i) => (
+ <span key={i} className="kicker flex items-center gap-8">
+ {t}
+ <span className="text-gold">/</span>
+ </span>
+ ))}
+ </div>
+ </div>
+ );
+}
apps/web/src/components/smooth-scroll.tsx +23 -0
@@ -0,0 +1,23 @@
+"use client";
+
+import { useEffect, type ReactNode } from "react";
+import Lenis from "lenis";
+
+export function SmoothScroll({ children }: { children: ReactNode }) {
+ useEffect(() => {
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
+ const lenis = new Lenis({ duration: 1.1, smoothWheel: true });
+ let raf = 0;
+ const loop = (t: number) => {
+ lenis.raf(t);
+ raf = requestAnimationFrame(loop);
+ };
+ raf = requestAnimationFrame(loop);
+ return () => {
+ cancelAnimationFrame(raf);
+ lenis.destroy();
+ };
+ }, []);
+
+ return <>{children}</>;
+}
pnpm-lock.yaml +21 -0
@@ -53,6 +53,9 @@ importers:
'@trpc/server':
specifier: ^11.0.0
version: 11.18.0(typescript@5.9.3)
+ lenis:
+ specifier: ^1.3.24
+ version: 1.3.24(react@19.2.4)
next:
specifier: 16.2.9
version: 16.2.9(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -2231,6 +2234,20 @@ packages:
resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==}
engines: {node: '>=0.10'}
+ lenis@1.3.24:
+ resolution: {integrity: sha512-UdWz0Bx/Z9dDgRdWe+N1dPmPWXGCF4VOTX3mugyX7lRpdjXG6ACfLI/cVmDOEMeQXf+j5Km/uKrvvf1X0k+u6g==}
+ peerDependencies:
+ '@nuxt/kit': '>=3.0.0'
+ react: '>=17.0.0'
+ vue: '>=3.0.0'
+ peerDependenciesMeta:
+ '@nuxt/kit':
+ optional: true
+ react:
+ optional: true
+ vue:
+ optional: true
+
levn@0.4.1:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
@@ -4752,6 +4769,10 @@ snapshots:
dependencies:
language-subtag-registry: 0.3.23
+ lenis@1.3.24(react@19.2.4):
+ optionalDependencies:
+ react: 19.2.4
+
levn@0.4.1:
dependencies:
prelude-ls: 1.2.1