2026-04-22 09:33:17 -07:00
import { useEffect , useRef , useState } from "react" ;
import { Link } from "react-router" ;
2026-04-22 14:44:09 -07:00
import { Users , Shuffle , Trophy , ChevronDown } from "lucide-react" ;
2026-04-22 09:33:17 -07:00
import { Button } from "~/components/ui/button" ;
import { Card , CardContent } from "~/components/ui/card" ;
import { SectionCardHeader } from "~/components/ui/SectionCardHeader" ;
// ─── Slot Machine ────────────────────────────────────────────────────────────
const SPORTS = [
"Football" , "Baseball" , "Hockey" , "Basketball" , "Soccer" , "Tennis" , "Golf" ,
"eSports" , "Formula 1" , "Aussie Rules" , "Lacrosse" , "Darts" , "College Football" , "Snooker" , "March Madness" ,
] ;
function shuffle < T > ( arr : T [ ] ) : T [ ] {
const a = [ . . . arr ] ;
for ( let i = a . length - 1 ; i > 0 ; i -- ) {
const j = Math . floor ( Math . random ( ) * ( i + 1 ) ) ;
[ a [ i ] , a [ j ] ] = [ a [ j ] , a [ i ] ] ;
}
return a ;
}
2026-04-22 14:44:09 -07:00
const TOTAL_TICKS = 12 ;
2026-04-22 09:33:17 -07:00
2026-04-22 14:44:09 -07:00
// WCAG 2.3.1: no more than 3 flashes/second → minimum interval 334 ms.
// Curve runs 800 ms → 340 ms over 12 ticks (ramps to full speed at tick 8).
2026-04-22 09:33:17 -07:00
function getInterval ( tick : number ) : number {
2026-04-22 14:44:09 -07:00
const pct = Math . min ( ( tick / TOTAL_TICKS ) * 1.5 , 1 ) ;
return Math . round ( 800 - ( 800 - 340 ) * ( pct * pct ) ) ;
2026-04-22 09:33:17 -07:00
}
function SlotMachineHeadline() {
2026-04-22 22:17:57 -07:00
const [ text , setText ] = useState ( SPORTS [ 0 ] ) ;
2026-04-22 09:33:17 -07:00
const [ landed , setLanded ] = useState ( false ) ;
const [ animKey , setAnimKey ] = useState ( 0 ) ;
const tickRef = useRef ( 0 ) ;
const timerRef = useRef < ReturnType < typeof setTimeout > | null > ( null ) ;
const sportsSeq = useRef < string [ ] > ( [ ] ) ;
useEffect ( ( ) = > {
sportsSeq . current = shuffle ( SPORTS ) ;
let idx = 0 ;
function tick() {
tickRef . current ++ ;
const sport = sportsSeq . current [ idx % sportsSeq . current . length ] ;
idx ++ ;
setText ( sport ) ;
setAnimKey ( ( k ) = > k + 1 ) ;
if ( tickRef . current >= TOTAL_TICKS ) {
setLanded ( true ) ;
return ;
}
timerRef . current = setTimeout ( tick , getInterval ( tickRef . current ) ) ;
}
2026-04-22 14:44:09 -07:00
timerRef . current = setTimeout ( tick , 800 ) ;
2026-04-22 09:33:17 -07:00
return ( ) = > {
if ( timerRef . current ) clearTimeout ( timerRef . current ) ;
} ;
} , [ ] ) ;
if ( landed ) {
return (
< span
className = "animate-sport-land block bg-clip-text text-transparent pb-[0.12em]"
style = { { backgroundImage : "linear-gradient(to bottom, #adf661, #2ce1c1)" } }
>
Every Sport .
< / span >
) ;
}
return (
< span key = { animKey } className = "animate-spin-in block text-white" >
{ text }
< / span >
) ;
}
// ─── Bracket Decoration ──────────────────────────────────────────────────────
2026-04-22 22:17:57 -07:00
function BracketDecor ( { flip = false , mobileTall = false } : { flip? : boolean ; mobileTall? : boolean } ) {
const base = flip ? "r" : "l" ;
const id = mobileTall ? ` ${ base } -m ` : base ;
const W = 399 ;
const H = mobileTall ? 2000 : 560 ;
const pad = 36 ;
const spacing = ( H - 2 * pad ) / 7 ;
const r1Ys = Array . from ( { length : 8 } , ( _ , i ) = > pad + i * spacing ) ;
const r2Ys = [ 0 , 1 , 2 , 3 ] . map ( ( i ) = > ( r1Ys [ i * 2 ] + r1Ys [ i * 2 + 1 ] ) / 2 ) ;
const r3Ys = [ 0 , 1 ] . map ( ( i ) = > ( r2Ys [ i * 2 ] + r2Ys [ i * 2 + 1 ] ) / 2 ) ;
const champY = ( r3Ys [ 0 ] + r3Ys [ 1 ] ) / 2 ;
const x0 = 4 ;
const x1 = 77 ;
const x2 = 161 ;
const x3 = 252 ;
const gradId = ` grad- ${ id } ` ;
const glowId = ` glow- ${ id } ` ;
const finalsGlowId = ` finals-glow- ${ id } ` ;
const finalsHaloId = ` finals-halo- ${ id } ` ;
const finalsFadeMaskGradId = ` finals-fade-mask-grad- ${ id } ` ;
const finalsFadeMaskId = ` finals-fade-mask- ${ id } ` ;
const finalsLineGlowId = ` finals-line-glow- ${ id } ` ;
2026-04-22 09:33:17 -07:00
return (
< svg
2026-04-22 22:17:57 -07:00
viewBox = { ` 0 0 ${ W } ${ H } ` }
2026-04-22 09:33:17 -07:00
style = { {
position : "absolute" ,
top : "50%" ,
transform : ` translateY(-52%) ${ flip ? " scaleX(-1)" : "" } ` ,
[ flip ? "right" : "left" ] : 0 ,
pointerEvents : "none" ,
2026-04-22 22:17:57 -07:00
height : mobileTall ? "auto" : "75vh" ,
width : mobileTall ? "32vw" : "auto" ,
2026-04-22 09:33:17 -07:00
} }
>
2026-04-22 22:17:57 -07:00
< defs >
< linearGradient id = { gradId } x1 = "0" y1 = "0" x2 = "0" y2 = { H } gradientUnits = "userSpaceOnUse" >
< stop offset = "0%" stopColor = "#adf661" / >
< stop offset = "100%" stopColor = "#2ce1c1" / >
< / linearGradient >
< filter id = { glowId } x = "-50%" y = "-50%" width = "200%" height = "200%" >
< feGaussianBlur stdDeviation = "3" result = "blur" / >
< feMerge >
< feMergeNode in = "blur" / >
< feMergeNode in = "SourceGraphic" / >
< / feMerge >
< / filter >
< filter id = { finalsGlowId } filterUnits = "userSpaceOnUse"
x = { x2 - 15 } y = { r3Ys [ 0 ] - 15 } width = { x3 - x2 + 30 } height = { r3Ys [ 1 ] - r3Ys [ 0 ] + 30 } >
< feGaussianBlur stdDeviation = "7" result = "blur" / >
< feMerge >
< feMergeNode in = "blur" / >
< feMergeNode in = "SourceGraphic" / >
< / feMerge >
< / filter >
< filter id = { finalsHaloId } filterUnits = "userSpaceOnUse"
x = { x2 - 40 } y = { r3Ys [ 0 ] - 40 } width = { x3 - x2 + 80 } height = { r3Ys [ 1 ] - r3Ys [ 0 ] + 80 } >
< feGaussianBlur stdDeviation = "16" / >
< / filter >
< linearGradient id = { finalsFadeMaskGradId } x1 = { x3 } y1 = "0" x2 = { W } y2 = "0" gradientUnits = "userSpaceOnUse" >
< stop offset = "0%" stopColor = "white" / >
< stop offset = "50%" stopColor = "white" / >
< stop offset = "100%" stopColor = "white" stopOpacity = "0" / >
< / linearGradient >
< mask id = { finalsFadeMaskId } maskUnits = "userSpaceOnUse" x = { x3 } y = { champY - 20 } width = { W - x3 } height = { 40 } >
< rect x = "0" y = "0" width = { W } height = { H } fill = { ` url(# ${ finalsFadeMaskGradId } ) ` } / >
< / mask >
< filter id = { finalsLineGlowId } filterUnits = "userSpaceOnUse" x = { x3 - 10 } y = { champY - 15 } width = { W - x3 + 20 } height = { 30 } >
< feGaussianBlur stdDeviation = "3" result = "blur" / >
< feMerge >
< feMergeNode in = "blur" / >
< feMergeNode in = "SourceGraphic" / >
< / feMerge >
< / filter >
< / defs >
< g filter = { ` url(# ${ finalsHaloId } ) ` } stroke = { ` url(# ${ gradId } ) ` } strokeWidth = { 2 } fill = "none" opacity = { 0.75 } >
{ r3Ys . map ( ( midY ) = > (
< line key = { midY } x1 = { x2 } y1 = { midY } x2 = { x3 } y2 = { midY } / >
) ) }
< line x1 = { x3 } y1 = { r3Ys [ 0 ] } x2 = { x3 } y2 = { r3Ys [ 1 ] } / >
< / g >
< g filter = { ` url(# ${ finalsGlowId } ) ` } stroke = { ` url(# ${ gradId } ) ` } strokeWidth = { 1.5 } fill = "none" >
{ r3Ys . map ( ( midY ) = > (
< line key = { midY } x1 = { x2 } y1 = { midY } x2 = { x3 } y2 = { midY } / >
) ) }
< line x1 = { x3 } y1 = { r3Ys [ 0 ] } x2 = { x3 } y2 = { r3Ys [ 1 ] } / >
< / g >
< g filter = { ` url(# ${ glowId } ) ` } stroke = { ` url(# ${ gradId } ) ` } strokeWidth = { 1.5 } fill = "none" >
{ r1Ys . map ( ( y ) = > (
< g key = { y } >
< circle cx = { x0 } cy = { y } r = { 3 } fill = { ` url(# ${ gradId } ) ` } stroke = "none" / >
< line x1 = { x0 } y1 = { y } x2 = { x1 } y2 = { y } / >
< / g >
) ) }
{ r2Ys . map ( ( midY , i ) = > (
< g key = { midY } >
< line x1 = { x1 } y1 = { r1Ys [ i * 2 ] } x2 = { x1 } y2 = { r1Ys [ i * 2 + 1 ] } / >
< circle cx = { x1 } cy = { midY } r = { 3 } fill = { ` url(# ${ gradId } ) ` } stroke = "none" / >
< line x1 = { x1 } y1 = { midY } x2 = { x2 } y2 = { midY } / >
< / g >
) ) }
{ r3Ys . map ( ( midY , i ) = > (
< g key = { midY } >
< line x1 = { x2 } y1 = { r2Ys [ i * 2 ] } x2 = { x2 } y2 = { r2Ys [ i * 2 + 1 ] } / >
< circle cx = { x2 } cy = { midY } r = { 3 } fill = { ` url(# ${ gradId } ) ` } stroke = "none" / >
< line x1 = { x2 } y1 = { midY } x2 = { x3 } y2 = { midY } / >
< / g >
) ) }
< line x1 = { x3 } y1 = { r3Ys [ 0 ] } x2 = { x3 } y2 = { r3Ys [ 1 ] } / >
< circle cx = { x3 } cy = { champY } r = { 3 } fill = { ` url(# ${ gradId } ) ` } stroke = "none" / >
< / g >
< line
x1 = { x3 } y1 = { champY } x2 = { W } y2 = { champY }
stroke = { ` url(# ${ gradId } ) ` }
strokeWidth = { 1.5 }
fill = "none"
filter = { ` url(# ${ finalsLineGlowId } ) ` }
mask = { ` url(# ${ finalsFadeMaskId } ) ` }
/ >
2026-04-22 09:33:17 -07:00
< / svg >
) ;
}
2026-04-22 14:44:09 -07:00
// ─── Scroll Hint ─────────────────────────────────────────────────────────────
function ScrollHint() {
const [ visible , setVisible ] = useState ( true ) ;
useEffect ( ( ) = > {
const onScroll = ( ) = > { if ( window . scrollY > 50 ) setVisible ( false ) ; } ;
window . addEventListener ( "scroll" , onScroll , { passive : true } ) ;
return ( ) = > window . removeEventListener ( "scroll" , onScroll ) ;
} , [ ] ) ;
return (
< div
className = "pointer-events-none absolute bottom-8 left-1/2 -translate-x-1/2 animate-bounce transition-opacity duration-500"
style = { { opacity : visible ? 0.35 : 0 } }
>
< ChevronDown className = "h-6 w-6 text-white" / >
< / div >
) ;
}
2026-04-22 09:33:17 -07:00
// ─── Sport Ticker ─────────────────────────────────────────────────────────────
const TICKER_ITEMS = [
. . . SPORTS . map ( ( s ) = > ( { sport : s.toUpperCase ( ) , key : ` ${ s } -a ` } ) ) ,
. . . SPORTS . map ( ( s ) = > ( { sport : s.toUpperCase ( ) , key : ` ${ s } -b ` } ) ) ,
] ;
function SportTicker() {
return (
< div className = "relative overflow-hidden pb-px" style = { { marginTop : "52px" } } >
< div
className = "pointer-events-none absolute inset-y-0 left-0 z-10 w-[60px]"
style = { { background : "linear-gradient(to right, #14171e, transparent)" } }
/ >
< div
className = "pointer-events-none absolute inset-y-0 right-0 z-10 w-[60px]"
style = { { background : "linear-gradient(to left, #14171e, transparent)" } }
/ >
< div className = "flex animate-ticker" style = { { width : "max-content" } } >
{ TICKER_ITEMS . map ( ( { sport , key } ) = > (
< span
key = { key }
className = "mx-1.5 shrink-0 rounded-[3px] px-3 py-1 text-[11px] font-bold uppercase tracking-[0.05em] border bg-card text-muted-foreground"
>
{ sport }
< / span >
) ) }
< / div >
< / div >
) ;
}
// ─── Static data ──────────────────────────────────────────────────────────────
const HOW_IT_WORKS_STEPS = [
{
icon : Users ,
title : "Create a League" ,
2026-04-22 14:44:09 -07:00
body : "Start a league, pick your sports (we can help), and invite your friends. It takes 30 seconds to set the stage." ,
2026-04-22 09:33:17 -07:00
} ,
{
icon : Shuffle ,
title : "Draft Your Teams" ,
2026-04-22 14:44:09 -07:00
body : "Pick teams from all the sports in order to build a superteam to win you bragging rights! Draft your way, slow or fast, with standard timers or high-pressure chess clocks." ,
2026-04-22 09:33:17 -07:00
} ,
{
icon : Trophy ,
title : "Watch & Win" ,
2026-04-22 14:44:09 -07:00
body : "Your teams earn points for how well they perform in the playoffs. Follow your rooting interests along the way, and gloat over your leaguemates when they fall." ,
2026-04-22 09:33:17 -07:00
} ,
] ;
const FEATURES = [
{
title : "Every sport, one league" ,
body : "NHL running at the same time as the NBA Finals? Draft from both. Brackt syncs across every major sports calendar." ,
} ,
{
title : "Live bracket view" ,
body : "Watch your bracket update as games end. Heartbreak and glory, all on one screen." ,
} ,
{
title : "Snake draft" ,
body : "Fair picks for everyone. No one ends up with dregs. The draft is the fun part — we let you enjoy it." ,
} ,
{
title : "Invite in one click" ,
body : "Share a link. Friends join, pick a name, they're in. No downloads, no friction." ,
} ,
] ;
// ─── Main Landing Page ────────────────────────────────────────────────────────
export function LandingPage() {
// Interleave step cards with gradient triangle separators
const howItWorksItems : React.ReactNode [ ] = [ ] ;
HOW_IT_WORKS_STEPS . forEach ( ( step , i ) = > {
howItWorksItems . push (
< Card key = { step . title } className = "flex-1 gap-2" >
< SectionCardHeader icon = { step . icon } title = { step . title } / >
< CardContent className = "px-3 sm:px-6" >
< p className = "text-sm text-muted-foreground" > { step . body } < / p >
< / CardContent >
< / Card >
) ;
if ( i < HOW_IT_WORKS_STEPS . length - 1 ) {
// Triangles reference #brackt-primary-gradient defined in <BracktGradients /> (mounted in root.tsx)
howItWorksItems . push (
2026-04-22 14:44:09 -07:00
< div key = { ` arrow- ${ step . title } ` } className = "flex shrink-0 items-center justify-center py-1 md:px-1 md:py-0" >
< svg width = "20" height = "20" viewBox = "0 0 20 20" className = "rotate-90 md:rotate-0" >
2026-04-22 09:33:17 -07:00
< polygon
points = "3,2 18,10 3,18"
fill = "url(#brackt-primary-gradient)"
stroke = "url(#brackt-primary-gradient)"
strokeWidth = { 3 }
strokeLinejoin = "round"
/ >
< / svg >
< / div >
) ;
}
} ) ;
return (
< div className = "animate-page-fade-in" >
{ /* ── Hero ── */ }
< section
className = "relative flex flex-col items-center justify-center overflow-hidden px-8"
style = { { minHeight : "calc(100vh - var(--navbar-height))" } }
>
2026-04-22 22:17:57 -07:00
{ /* Dot-grid texture */ }
< div
aria - hidden
className = "pointer-events-none absolute inset-0"
style = { {
backgroundImage : "radial-gradient(circle, rgba(255,255,255,0.07) 1px, transparent 1px)" ,
backgroundSize : "32px 32px" ,
maskImage : "radial-gradient(ellipse 85% 85% at 50% 50%, black 30%, transparent 100%)" ,
WebkitMaskImage : "radial-gradient(ellipse 85% 85% at 50% 50%, black 30%, transparent 100%)" ,
} }
/ >
{ /* Bracket decorations */ }
< div className = "lg:hidden" >
< BracketDecor mobileTall / >
< BracketDecor flip mobileTall / >
< / div >
2026-04-22 09:33:17 -07:00
< div className = "hidden lg:block" >
< BracketDecor / >
< BracketDecor flip / >
< / div >
2026-04-22 14:44:09 -07:00
< ScrollHint / >
2026-04-22 09:33:17 -07:00
{ /* Center content */ }
2026-04-22 22:17:57 -07:00
< div className = "relative z-10 w-[80%] lg:w-full max-w-[680px] text-center px-6 py-4 lg:px-0 lg:py-0" >
2026-04-22 09:33:17 -07:00
< h1
className = "mb-0 leading-[0.95] tracking-[-0.04em]"
2026-04-22 14:44:09 -07:00
style = { { fontSize : "clamp(38px, 8vw, 88px)" , fontWeight : 900 } }
2026-04-22 09:33:17 -07:00
>
< span className = "block text-white" > Fantasy < / span >
< SlotMachineHeadline / >
< / h1 >
< p
className = "mx-auto mt-8 text-muted-foreground"
2026-04-22 22:17:57 -07:00
style = { { fontSize : "16px" , fontWeight : 400 , lineHeight : 1.65 , maxWidth : "414px" } }
2026-04-22 09:33:17 -07:00
>
2026-04-22 22:17:57 -07:00
Draft teams across every league and sport , like the NFL , NHL , EPL , F1 , and more .
Compete against your friends in a season - long league .
2026-04-22 09:33:17 -07:00
< / p >
2026-04-22 14:44:09 -07:00
< div className = "mt-8 flex flex-col items-center justify-center gap-2.5 sm:flex-row" >
2026-04-22 09:33:17 -07:00
< Button asChild size = "lg" className = "text-sm font-extrabold" >
< Link to = "/leagues/new" > Create a League < / Link >
< / Button >
< Button asChild variant = "outline" size = "lg" className = "text-sm font-semibold" >
< Link to = "/how-to-play" > How it works < / Link >
< / Button >
< / div >
< SportTicker / >
< / div >
< / section >
{ /* ── How It Works ── */ }
< section className = "mx-auto w-full max-w-[1100px] px-10 py-18" >
< div className = "flex flex-col gap-4 md:flex-row md:items-stretch" >
{ howItWorksItems }
< / div >
< / section >
{ /* ── Features Grid ── */ }
< section className = "mx-auto w-full max-w-[1100px] px-10 pb-18" >
< div className = "overflow-hidden rounded-[6px] border" >
< div className = "grid grid-cols-1 md:grid-cols-2" style = { { gap : "1px" , background : "rgb(255 255 255 / 10%)" } } >
{ FEATURES . map ( ( { title , body } ) = > (
< div key = { title } className = "bg-card p-7 transition-colors hover:bg-[#22252e]" >
< h3 className = "mb-2 text-base font-extrabold tracking-[-0.01em]" > { title } < / h3 >
< p className = "text-sm leading-[1.65] text-muted-foreground" > { body } < / p >
< / div >
) ) }
< / div >
< / div >
< / section >
{ /* ── CTA Banner ── */ }
< section className = "mx-auto w-full max-w-[1100px] px-10 pb-20" >
< div className = "bg-card border flex flex-col gap-8 rounded-[6px] p-12 md:flex-row md:items-center md:justify-between" >
< div >
< h2
className = "mb-3 font-black tracking-[-0.025em]"
style = { { fontSize : "clamp(22px, 3vw, 34px)" } }
>
Your group chat is waiting for this .
< / h2 >
< p className = "text-sm leading-[1.65] text-muted-foreground" style = { { maxWidth : "460px" } } >
Free to play . Five minutes to set up . The real question is — who drafts the Colorado Avalanche ?
< / p >
< / div >
< Button asChild size = "lg" className = "shrink-0 text-sm font-extrabold" >
< Link to = "/leagues/new" > Create a League — It & apos ; s Free < / Link >
< / Button >
< / div >
< / section >
< / div >
) ;
}