steelforesight/src/components/ui/magic-text.tsx

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>
)
}