Bewegung
Text reveal
Die Wörter werden von blass zu voll, während man durch einen Absatz scrollt.
Vorschau
Verwendung
text-reveal-demo.tsx
import { TextReveal } from '@/components/text-reveal'
export default function TextRevealDemo() {
return (
<TextReveal className="font-serif text-3xl leading-snug">
A good shop page answers three questions before the visitor asks them: what it costs, when it arrives, and how to send it back.
</TextReveal>
)
}Quellcode
text-reveal.tsx
import { useEffect, useRef, useState } from 'react'
import { useReducedMotion } from '@/registry/manniche/hooks/use-reduced-motion'
import { cn } from '@/lib/utils'
export type TextRevealProps = {
/** Plain text. Each word lights up as the paragraph scrolls past. */
children: string
className?: string
}
/**
* Words go from faint to full as the reader scrolls through the paragraph.
* The text is always in the DOM in full, so screen readers and search engines read it normally.
* Under reduced motion every word is at full strength from the start.
*/
export function TextReveal({ children, className }: TextRevealProps) {
const reduced = useReducedMotion()
const el = useRef<HTMLParagraphElement>(null)
const words = children.split(/(\s+)/)
const count = words.filter((w) => !/^\s+$/.test(w)).length
const [lit, setLit] = useState(count)
useEffect(() => {
if (reduced) {
setLit(count)
return
}
let frame = 0
const run = () => {
cancelAnimationFrame(frame)
frame = requestAnimationFrame(() => {
const p = el.current
if (!p) return
const r = p.getBoundingClientRect()
const t = Math.min(1, Math.max(0, (innerHeight * 0.85 - r.top) / (r.height + innerHeight * 0.35)))
setLit(Math.round(t * count))
})
}
run()
addEventListener('scroll', run, { passive: true })
addEventListener('resize', run)
return () => {
cancelAnimationFrame(frame)
removeEventListener('scroll', run)
removeEventListener('resize', run)
}
}, [reduced, count])
let i = 0
return (
<p ref={el} className={cn('text-pretty', className)}>
{words.map((w, j) => {
if (/^\s+$/.test(w)) return w
const on = i++ < lit
return (
<span key={j} className={cn('transition-opacity duration-200', on ? 'opacity-100' : 'opacity-20')}>
{w}
</span>
)
})}
</p>
)
}use-reduced-motion.ts
import { useSyncExternalStore } from 'react'
const QUERY = '(prefers-reduced-motion: reduce)'
function subscribe(onChange: () => void) {
const mq = window.matchMedia(QUERY)
mq.addEventListener('change', onChange)
return () => mq.removeEventListener('change', onChange)
}
/** True when the visitor has asked the system for less motion. */
export function useReducedMotion() {
return useSyncExternalStore(
subscribe,
() => window.matchMedia(QUERY).matches,
() => false,
)
}Installieren
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/text-reveal.jsonMit eingetragener Registry (components.json)
npx shadcn@latest add @manniche/text-reveal