Mikkel Manniche
Dansk English Deutsch

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.json

Mit eingetragener Registry (components.json)

npx shadcn@latest add @manniche/text-reveal

Abhängigkeiten

Dateien

  • text-reveal.tsx
  • use-reduced-motion.ts