This commit is contained in:
Vula Builder
2026-10-11 14:20:26 +00:00
parent 1194c52145
commit 84b00541b5
+185
View File
@@ -0,0 +1,185 @@
"use client"
import Image from "next/image"
import Link from "next/link"
import { useEffect, useRef, useState } from "react"
export interface HeroSlide {
image: string
alt: string
eyebrow: string
headline: string
subtitle: string
ctaText: string
ctaHref: string
/** The business's own headline: the page's one h1. */
primary?: boolean
}
const SLIDE_MS = 6500
function splitEmphasis(headline: string): { lead: string; emph: string } {
const words = headline.split(/\s+/)
if (words.length < 3) return { lead: headline, emph: "" }
const n = words.length > 5 ? 2 : 1
return { lead: words.slice(0, -n).join(" ") + " ", emph: words.slice(-n).join(" ") }
}
export default function HeroSlideshow({ slides }: { slides: HeroSlide[] }) {
const count = slides.length
const [active, setActive] = useState(0)
const [paused, setPaused] = useState(false)
const [held, setHeld] = useState(false)
const [still, setStill] = useState(false)
const touchX = useRef<number | null>(null)
useEffect(() => {
const query = window.matchMedia("(prefers-reduced-motion: reduce)")
const sync = () => setStill(query.matches)
sync()
query.addEventListener("change", sync)
return () => query.removeEventListener("change", sync)
}, [])
const playing = count > 1 && !paused && !held && !still
useEffect(() => {
if (!playing) return
const timer = window.setTimeout(() => setActive((a) => (a + 1) % count), SLIDE_MS)
return () => window.clearTimeout(timer)
}, [playing, active, count])
const go = (i: number) => setActive(((i % count) + count) % count)
return (
<section
data-vula-section="hero"
id="hero"
aria-roledescription="carousel"
aria-label="Highlights"
className="relative overflow-hidden text-white"
onMouseEnter={() => setHeld(true)}
onMouseLeave={() => setHeld(false)}
onFocus={() => setHeld(true)}
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setHeld(false)
}}
onKeyDown={(e) => {
if (count < 2) return
if (e.key === "ArrowRight") go(active + 1)
if (e.key === "ArrowLeft") go(active - 1)
}}
onTouchStart={(e) => {
touchX.current = e.touches[0]?.clientX ?? null
}}
onTouchEnd={(e) => {
const from = touchX.current
const to = e.changedTouches[0]?.clientX
touchX.current = null
if (count < 2 || from == null || to == null || Math.abs(to - from) < 40) return
go(active + (to < from ? 1 : -1))
}}
>
<div className="grid" aria-live={playing ? "off" : "polite"}>
{slides.map((slide, i) => {
const shown = i === active
const { lead, emph } = splitEmphasis(slide.headline)
const Heading = slide.primary ? "h1" : "h2"
return (
<div
key={i}
role="group"
aria-roledescription="slide"
aria-label={`${i + 1} of ${count}`}
aria-hidden={!shown}
className={`relative col-start-1 row-start-1 min-h-svh transition-opacity duration-1000 ease-out ${shown ? "opacity-100 z-10" : "opacity-0 z-0 pointer-events-none"}`}
>
{!slide.headline ? (
<>
{/* A poster: its own words are in the picture, so it is shown
whole over a blurred copy of itself — never cropped, never
darkened, never written over. */}
<div className="absolute inset-0 overflow-hidden" aria-hidden="true">
<Image src={slide.image} alt="" fill className="object-cover scale-110 blur-2xl brightness-50" sizes="100vw" />
</div>
<div className="absolute inset-x-4 top-20 bottom-20 sm:inset-x-10">
<Image src={slide.image} alt={slide.alt} fill className="object-contain" sizes="100vw" priority={i === 0} />
</div>
{slide.ctaText && slide.ctaHref && (
<div className="absolute inset-x-0 bottom-24 z-10 flex justify-center px-6">
<Link href={slide.ctaHref} tabIndex={shown ? undefined : -1} className="inline-block border border-accent text-accent px-9 py-4 text-[12.5px] uppercase tracking-[0.24em] hover:bg-accent hover:text-accent-foreground transition-all duration-300">
{slide.ctaText}
</Link>
</div>
)}
</>
) : (
<>
{slide.image ? (
<div className={`absolute inset-0 transition-transform duration-[7000ms] ease-out motion-reduce:transition-none ${shown && !still ? "scale-105" : "scale-100"}`}>
<Image src={slide.image} alt={slide.alt} fill className="object-cover" sizes="100vw" priority={i === 0} />
</div>
) : (
<div className="absolute inset-0 bg-gradient-to-br from-primary to-secondary" />
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/50 to-black/30" />
<div aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-0 h-40 bg-gradient-to-b from-black/50 to-transparent" />
<div className="relative z-10 flex min-h-svh flex-col items-center justify-center px-6 pt-24 pb-28 text-center">
{slide.eyebrow && <p className="uppercase tracking-[0.32em] text-[11.5px] text-accent mb-4">{slide.eyebrow}</p>}
<Heading className="text-5xl lg:text-6xl font-normal tracking-[0.01em] mb-6 max-w-4xl">
{lead}{emph && <em className="italic text-accent">{emph}</em>}
</Heading>
{slide.subtitle && <p className="text-lg lg:text-xl text-white/80 max-w-2xl mb-9">{slide.subtitle}</p>}
{slide.ctaText && slide.ctaHref && (
<Link href={slide.ctaHref} tabIndex={shown ? undefined : -1} className="inline-block border border-accent text-accent px-9 py-4 text-[12.5px] uppercase tracking-[0.24em] hover:bg-accent hover:text-accent-foreground transition-all duration-300">
{slide.ctaText}
</Link>
)}
</div>
</>
)}
</div>
)
})}
</div>
{count > 1 && (
<div className="absolute inset-x-0 bottom-6 z-20 flex items-center justify-center gap-3">
<button type="button" onClick={() => go(active - 1)} aria-label="Previous slide" className="flex h-11 w-11 items-center justify-center rounded-full bg-black/35 text-white backdrop-blur-sm hover:bg-black/55 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white transition-colors">
<svg aria-hidden="true" viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M15 18l-6-6 6-6" /></svg>
</button>
<div className="flex items-center">
{slides.map((slide, i) => (
<button
key={i}
type="button"
onClick={() => go(i)}
aria-label={`Go to slide ${i + 1}: ${slide.headline || slide.alt}`}
aria-current={i === active ? "true" : undefined}
className="p-2"
>
<span className={`block h-2 rounded-full transition-all duration-300 ${i === active ? "w-8 bg-white" : "w-2 bg-white/50 hover:bg-white/80"}`} />
</button>
))}
</div>
<button type="button" onClick={() => go(active + 1)} aria-label="Next slide" className="flex h-11 w-11 items-center justify-center rounded-full bg-black/35 text-white backdrop-blur-sm hover:bg-black/55 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white transition-colors">
<svg aria-hidden="true" viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M9 18l6-6-6-6" /></svg>
</button>
{!still && (
<button
type="button"
onClick={() => setPaused((p) => !p)}
aria-label={paused ? "Play slideshow" : "Pause slideshow"}
className="flex h-11 w-11 items-center justify-center rounded-full bg-black/35 text-white backdrop-blur-sm hover:bg-black/55 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white transition-colors"
>
{paused ? (
<svg aria-hidden="true" viewBox="0 0 24 24" className="h-4 w-4" fill="currentColor"><path d="M8 5v14l11-7z" /></svg>
) : (
<svg aria-hidden="true" viewBox="0 0 24 24" className="h-4 w-4" fill="currentColor"><path d="M7 5h4v14H7zM13 5h4v14h-4z" /></svg>
)}
</button>
)}
</div>
)}
</section>
)
}