since3-portal/modules/templates/catalog/shared/motion.tsx
2026-10-11 18:22:32 +02:00

339 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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)
// Schließendes Sternchen auch vor Satzzeichen erkennen: "*Herz*," → "Herz,"
const closing = word.match(/\*([.,;:!?…"“”)]*)$/)
const closes = closing !== null
if (closing) word = word.slice(0, closing.index) + closing[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).
// `footer` bekommt den Fortschritt (0–1), z. B. für eine Fortschrittsleiste unter der Reihe.
export function HorizontalScroll({ children, header, footer, className, trackClassName }: {
children: ReactNode
header?: ReactNode
footer?: (progress: MotionValue<number>) => 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>
{footer?.(scrollYProgress)}
</div>
</section>
)
}