since3-portal/modules/templates/catalog/shared/motion.tsx
2026-10-05 08:23:02 +02:00

334 lines
12 KiB
TypeScript

"use client"
import {
animate,
AnimatePresence,
motion,
useInView,
useMotionValue,
useMotionValueEvent,
useReducedMotion,
useScroll,
useTransform,
type MotionValue,
} from "motion/react"
import { Fragment, useEffect, useRef, useState, type ReactNode } from "react"
import { PlusIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { TImage } from "./media"
export const ease = [0.22, 1, 0.36, 1] as const
const inView = { once: true, margin: "0px 0px -10% 0px" } as const
type Token = { word: string; em: boolean }
// Zeilen per "\n", *Wörter in Sternchen* werden hervorgehoben (kursiv/Akzent, je nach Vorlage)
export function parseRich(text: string): Token[][] {
let em = false
return text.split("\n").map((line) =>
line.split(" ").filter(Boolean).map((raw) => {
let word = raw
const opens = word.startsWith("*")
if (opens) word = word.slice(1)
const closes = word.endsWith("*")
if (closes) word = word.slice(0, -1)
if (opens) em = true
const token = { word, em }
if (closes) em = false
return token
})
)
}
// Navigation: bekommt nach dem ersten Scrollen einen Hintergrund, versteckt sich beim Runterscrollen
export function useScrollNav() {
const { scrollY } = useScroll()
const [state, setState] = useState({ scrolled: false, hidden: false })
useMotionValueEvent(scrollY, "change", (y) => {
const next = { scrolled: y > 40, hidden: y > (scrollY.getPrevious() ?? 0) && y > 240 }
setState((current) => (current.scrolled === next.scrolled && current.hidden === next.hidden ? current : next))
})
return state
}
// Schriftgröße für einen Schriftzug über die volle Breite, abhängig von der Länge des Namens
export function wordmarkSize(name: string, max = 26) {
return `${Math.min(max, 140 / Math.max(name.length, 1)).toFixed(2)}vw`
}
export function Rich({ text, emClassName }: { text: string; emClassName?: string }) {
return parseRich(text).map((tokens, l) => (
<Fragment key={l}>
{l > 0 && <br />}
{tokens.map((token, i) => (
<Fragment key={i}>
{i > 0 && " "}
{token.em ? <em className={emClassName}>{token.word}</em> : token.word}
</Fragment>
))}
</Fragment>
))
}
export function Reveal({ children, className, delay = 0, y = 32 }: {
children: ReactNode
className?: string
delay?: number
y?: number
}) {
return (
<motion.div
className={className}
initial={{ opacity: 0, y }}
whileInView={{ opacity: 1, y: 0 }}
viewport={inView}
transition={{ duration: 0.9, ease, delay }}
>
{children}
</motion.div>
)
}
const textTags = { h1: motion.h1, h2: motion.h2, h3: motion.h3, p: motion.p }
// Wörter gleiten zeilenweise aus einer Maske nach oben
export function RevealWords({ text, as = "h2", className, emClassName, delay = 0, onMount = false }: {
text: string
as?: keyof typeof textTags
className?: string
emClassName?: string
delay?: number
onMount?: boolean
}) {
const Tag = textTags[as]
const lines = parseRich(text)
const trigger = onMount ? { animate: "show" } : { whileInView: "show", viewport: inView }
return (
<Tag className={className} initial="hidden" {...trigger}>
<span className="sr-only">{text.replace(/\*/g, "")}</span>
{lines.map((tokens, l) => {
const offset = lines.slice(0, l).reduce((sum, line) => sum + line.length, 0)
return (
<span key={l} aria-hidden className="block">
{tokens.map((token, i) => (
<Fragment key={i}>
{i > 0 && " "}
<span className="mb-[-0.14em] inline-block overflow-hidden pb-[0.14em] align-top">
<motion.span
className={cn("inline-block", token.em && emClassName)}
variants={{
hidden: { y: "105%" },
show: { y: "0%", transition: { duration: 1.05, ease, delay: delay + (offset + i) * 0.055 } },
}}
>
{token.word}
</motion.span>
</span>
</Fragment>
))}
</span>
)
})}
</Tag>
)
}
function ScrollWord({ progress, range, children }: { progress: MotionValue<number>; range: [number, number]; children: string }) {
const opacity = useTransform(progress, range, [0.14, 1])
return <motion.span style={{ opacity }}>{children}</motion.span>
}
// Wörter leuchten beim Scrollen nacheinander auf
export function ScrollWords({ text, className }: { text: string; className?: string }) {
const ref = useRef<HTMLParagraphElement>(null)
const { scrollYProgress } = useScroll({ target: ref, offset: ["start 0.85", "end 0.45"] })
const words = text.split(/\s+/).filter(Boolean)
return (
<p ref={ref} className={className}>
{words.map((word, i) => (
<Fragment key={i}>
{i > 0 && " "}
<ScrollWord progress={scrollYProgress} range={[i / words.length, (i + 1) / words.length]}>
{word}
</ScrollWord>
</Fragment>
))}
</p>
)
}
export function ParallaxImage({ src, alt, className, imageClassName, offset = 60, sizes, eager }: {
src: string
alt?: string
className?: string
imageClassName?: string
offset?: number
sizes?: string
eager?: boolean
}) {
const ref = useRef<HTMLDivElement>(null)
const { scrollYProgress } = useScroll({ target: ref, offset: ["start end", "end start"] })
const y = useTransform(scrollYProgress, [0, 1], [-offset, offset])
return (
<div ref={ref} className={cn("relative overflow-hidden", className)}>
<motion.div className="absolute inset-x-0" style={{ y, top: -offset, bottom: -offset }}>
<TImage src={src} alt={alt} sizes={sizes} eager={eager} className={imageClassName} />
</motion.div>
</div>
)
}
// Endlos-Laufband; jede Kopie enthält den Inhalt doppelt, damit auch breite Bildschirme gefüllt sind
export function Marquee({ children, className, duration = 40, reverse = false }: {
children: ReactNode
className?: string
duration?: number
reverse?: boolean
}) {
return (
<div className={cn("flex overflow-hidden", className)}>
{[0, 1].map((copy) => (
<motion.div
key={copy}
aria-hidden={copy === 1}
className="flex shrink-0"
animate={{ x: reverse ? ["-100%", "0%"] : ["0%", "-100%"] }}
transition={{ duration, ease: "linear", repeat: Infinity }}
>
{[0, 1].map((repeat) => (
<div key={repeat} className="flex shrink-0 items-center">{children}</div>
))}
</motion.div>
))}
</div>
)
}
// Zählt deutsch formatierte Zahlen ("1.200", "4,9") hoch, sobald sie sichtbar werden
export function Counter({ value, className }: { value: string; className?: string }) {
const ref = useRef<HTMLSpanElement>(null)
const visible = useInView(ref, inView)
const reduceMotion = useReducedMotion()
useEffect(() => {
const target = parseFloat(value.replace(/\./g, "").replace(",", "."))
if (!visible || reduceMotion || Number.isNaN(target)) return
const decimals = value.split(",")[1]?.length ?? 0
const format = (n: number) => n.toLocaleString("de-DE", { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
const controls = animate(0, target, {
duration: 1.8,
ease,
onUpdate: (n) => {
if (ref.current) ref.current.textContent = format(n)
},
})
return () => controls.stop()
}, [visible, reduceMotion, value])
return <span ref={ref} className={className}>{value}</span>
}
// Schritt-Wechsel in Formularen mit mehreren Schritten (Buchung, Bewerbung, Quiz)
export function StepPanel({ step, children, className }: { step: number | string; children: ReactNode; className?: string }) {
return (
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={step}
className={className}
initial={{ opacity: 0, x: 28 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -28 }}
transition={{ duration: 0.35, ease }}
>
{children}
</motion.div>
</AnimatePresence>
)
}
export function Accordion({ items, className, itemClassName, questionClassName, answerClassName }: {
items: { question: string; answer: string }[]
className?: string
itemClassName?: string
questionClassName?: string
answerClassName?: string
}) {
const [open, setOpen] = useState<number | null>(0)
return (
<div className={className}>
{items.map((item, i) => (
<div key={i} className={itemClassName}>
<button
type="button"
aria-expanded={open === i}
onClick={() => setOpen(open === i ? null : i)}
className={cn("flex w-full cursor-pointer items-center justify-between gap-6 text-left", questionClassName)}
>
<span>{item.question}</span>
<PlusIcon className={cn("size-5 shrink-0 transition-transform duration-300", open === i && "rotate-45")} />
</button>
<AnimatePresence initial={false}>
{open === i && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.4, ease }}
className="overflow-hidden"
>
<p className={answerClassName}>{item.answer}</p>
</motion.div>
)}
</AnimatePresence>
</div>
))}
</div>
)
}
// Vertikales Scrollen schiebt eine Bildreihe horizontal durch (Sektion bleibt dabei stehen)
export function HorizontalScroll({ children, header, className, trackClassName }: {
children: ReactNode
header?: ReactNode
className?: string
trackClassName?: string
}) {
const sectionRef = useRef<HTMLElement>(null)
const trackRef = useRef<HTMLDivElement>(null)
const distance = useMotionValue(0)
const { scrollYProgress } = useScroll({ target: sectionRef, offset: ["start start", "end end"] })
const x = useTransform(() => -scrollYProgress.get() * distance.get())
useEffect(() => {
const section = sectionRef.current
const track = trackRef.current
if (!section || !track) return
const observer = new ResizeObserver(() => {
const overflow = Math.max(0, track.scrollWidth - document.documentElement.clientWidth)
distance.set(overflow)
section.style.height = `calc(100vh + ${overflow}px)`
})
observer.observe(track)
observer.observe(document.documentElement)
return () => observer.disconnect()
}, [distance])
return (
<section ref={sectionRef} className={cn("relative h-screen", className)}>
<div className="sticky top-0 flex h-screen flex-col justify-center overflow-hidden">
{header}
<motion.div ref={trackRef} style={{ x }} className={cn("flex w-max", trackClassName)}>
{children}
</motion.div>
</div>
</section>
)
}