Deploy
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user