112 lines
3.4 KiB
TypeScript
112 lines
3.4 KiB
TypeScript
import * as React from 'react'
|
|
import { motion, useScroll, useTransform, type MotionValue } from 'framer-motion'
|
|
import { useRef } from 'react'
|
|
|
|
export interface MagicTextProps {
|
|
text: string
|
|
/** dir of the text — affects word flow */
|
|
dir?: 'rtl' | 'ltr'
|
|
/** inline style applied to the paragraph (font-size, color, line-height…) */
|
|
style?: React.CSSProperties
|
|
className?: string
|
|
}
|
|
|
|
interface WordProps {
|
|
children: string
|
|
progress: MotionValue<number>
|
|
range: [number, number]
|
|
}
|
|
|
|
const Word: React.FC<WordProps> = ({ children, progress, range }) => {
|
|
const opacity = useTransform(progress, range, [0, 1])
|
|
return (
|
|
<span style={{ position: 'relative', marginInlineEnd: '0.28em' }}>
|
|
<span style={{ position: 'absolute', inset: 0, opacity: 0.18 }}>{children}</span>
|
|
<motion.span style={{ opacity }}>{children}</motion.span>
|
|
</span>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Scroll-driven per-word reveal. Each word fades from faint → solid as the
|
|
* paragraph scrolls through the viewport. Adapted from the HextaUI MagicText
|
|
* recipe to framer-motion + RTL body text.
|
|
*/
|
|
export const MagicText: React.FC<MagicTextProps> = ({ text, dir = 'rtl', style, className }) => {
|
|
const container = useRef<HTMLParagraphElement>(null)
|
|
const { scrollYProgress } = useScroll({
|
|
target: container,
|
|
offset: ['start 0.9', 'start 0.35'],
|
|
})
|
|
const words = text.split(' ')
|
|
|
|
return (
|
|
<p
|
|
ref={container}
|
|
dir={dir}
|
|
className={className}
|
|
style={{ display: 'flex', flexWrap: 'wrap', textAlign: 'justify', ...style }}
|
|
>
|
|
{words.map((word, i) => {
|
|
const start = i / words.length
|
|
const end = start + 1 / words.length
|
|
return (
|
|
<Word key={i} progress={scrollYProgress} range={[start, end]}>
|
|
{word}
|
|
</Word>
|
|
)
|
|
})}
|
|
</p>
|
|
)
|
|
}
|
|
|
|
export interface MagicTextGroupProps {
|
|
/** ordered list of paragraphs — revealed sequentially, one finishing before the next starts */
|
|
paragraphs: string[]
|
|
dir?: 'rtl' | 'ltr'
|
|
style?: React.CSSProperties
|
|
className?: string
|
|
}
|
|
|
|
/**
|
|
* Reveals several paragraphs with a SINGLE shared scroll progress. Every word
|
|
* across all paragraphs gets a global range, so paragraph 1 fully reveals
|
|
* before paragraph 2 begins (instead of all paragraphs animating at once).
|
|
*/
|
|
export const MagicTextGroup: React.FC<MagicTextGroupProps> = ({ paragraphs, dir = 'rtl', style, className }) => {
|
|
const container = useRef<HTMLDivElement>(null)
|
|
const { scrollYProgress } = useScroll({
|
|
target: container,
|
|
offset: ['start 0.9', 'end 0.4'],
|
|
})
|
|
|
|
// tokenize once and assign a continuous global index to every word
|
|
const wordsPerPara = paragraphs.map((p) => p.split(' '))
|
|
const totalWords = wordsPerPara.reduce((n, w) => n + w.length, 0)
|
|
let globalIndex = 0
|
|
|
|
return (
|
|
<div ref={container}>
|
|
{wordsPerPara.map((words, pi) => (
|
|
<p
|
|
key={pi}
|
|
dir={dir}
|
|
className={className}
|
|
style={{ display: 'flex', flexWrap: 'wrap', textAlign: 'justify', ...style }}
|
|
>
|
|
{words.map((word, wi) => {
|
|
const start = globalIndex / totalWords
|
|
const end = (globalIndex + 1) / totalWords
|
|
globalIndex += 1
|
|
return (
|
|
<Word key={wi} progress={scrollYProgress} range={[start, end]}>
|
|
{word}
|
|
</Word>
|
|
)
|
|
})}
|
|
</p>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|