Rotating Text does not work with gradient text CSS, only in Chrome. #859
-
|
I have the following source code. import RotatingText from "@/components/RotatingText";
export default function Home() {
return (
<div className="border inline-block m-10">
<RotatingText
texts={["Hello", "こんにちは", "你好"]}
className="inline-block text-transparent bg-linear-to-r bg-clip-text from-green-400 to-blue-500"
/>
<p className="inline-block text-transparent bg-linear-to-r bg-clip-text from-green-400 to-blue-500">Hello</p>
</div>
);
}When this page is viewed via Firefox, everything works perfectly. However, when viewed via Chrome, Rotating Text seems to be invisible.
Minimal reproducible repo: https://github.com/Toshimichi0915/react-bits-debug Please help me debug this bug. Thanks! |
Beta Was this translation helpful? Give feedback.
Replies: 1 comment
-
|
For those who may encounter the same issue, use this component instead. "use client"
import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from "react"
import {
AnimatePresence,
motion,
type Target,
type TargetAndTransition,
Transition,
type VariantLabels,
} from "motion/react"
import { cn } from "@/lib/utils"
export interface RotatingTextRef {
next: () => void
previous: () => void
jumpTo: (index: number) => void
reset: () => void
}
export interface RotatingTextProps extends Omit<
React.ComponentPropsWithoutRef<typeof motion.span>,
"children" | "transition" | "initial" | "animate" | "exit"
> {
texts: string[]
transition?: Transition
initial?: boolean | Target | VariantLabels
animate?: boolean | VariantLabels | TargetAndTransition
exit?: Target | VariantLabels
animatePresenceMode?: "sync" | "wait"
animatePresenceInitial?: boolean
rotationInterval?: number
staggerDuration?: number
staggerFrom?: "first" | "last" | "center" | "random" | number
loop?: boolean
auto?: boolean
splitBy?: string
onNext?: (index: number) => void
mainClassName?: string
splitLevelClassName?: string
elementLevelClassName?: string
gradient?: {
from: string
to: string
direction?: string
}
}
const RotatingText = forwardRef<RotatingTextRef, RotatingTextProps>(
(
{
texts,
transition = { type: "spring", damping: 25, stiffness: 300 },
initial = { y: "100%", opacity: 0 },
animate = { y: 0, opacity: 1 },
exit = { y: "-120%", opacity: 0 },
animatePresenceMode = "wait",
animatePresenceInitial = false,
rotationInterval = 2000,
staggerDuration = 0,
staggerFrom = "first",
loop = true,
auto = true,
splitBy = "characters",
onNext,
mainClassName,
splitLevelClassName,
elementLevelClassName,
gradient,
...rest
},
ref,
) => {
const [currentTextIndex, setCurrentTextIndex] = useState<number>(0)
const splitIntoCharacters = (text: string): string[] => {
if (typeof Intl !== "undefined" && Intl.Segmenter) {
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" })
return Array.from(segmenter.segment(text), (segment) => segment.segment)
}
return Array.from(text)
}
const elements = useMemo(() => {
const currentText: string = texts[currentTextIndex]
if (splitBy === "characters") {
const words = currentText.split(" ")
return words.map((word, i) => ({
characters: splitIntoCharacters(word),
needsSpace: i !== words.length - 1,
}))
}
if (splitBy === "words") {
return currentText.split(" ").map((word, i, arr) => ({
characters: [word],
needsSpace: i !== arr.length - 1,
}))
}
if (splitBy === "lines") {
return currentText.split("\n").map((line, i, arr) => ({
characters: [line],
needsSpace: i !== arr.length - 1,
}))
}
return currentText.split(splitBy).map((part, i, arr) => ({
characters: [part],
needsSpace: i !== arr.length - 1,
}))
}, [texts, currentTextIndex, splitBy])
const measureRef = useRef<HTMLSpanElement>(null)
const allCharsWithSpaces = useMemo(() => {
const result: string[] = []
for (const wordObj of elements) {
for (const char of wordObj.characters) {
result.push(char)
}
if (wordObj.needsSpace) {
result.push(" ")
}
}
return result
}, [elements])
const globalIndexMap = useMemo(() => {
const map = new Map<string, number>()
let globalIndex = 0
for (let wordIndex = 0; wordIndex < elements.length; wordIndex++) {
for (let charIndex = 0; charIndex < elements[wordIndex].characters.length; charIndex++) {
map.set(`${wordIndex}-${charIndex}`, globalIndex)
globalIndex++
}
if (elements[wordIndex].needsSpace) {
globalIndex++ // account for space
}
}
return map
}, [elements])
const [charWidths, setCharWidths] = useState<number[] | null>(null)
useLayoutEffect(() => {
if (!gradient || !measureRef.current) return
const spans = measureRef.current.querySelectorAll<HTMLSpanElement>("[data-measure]")
if (spans.length === 0) return
const widths = Array.from(spans, (span) => span.getBoundingClientRect().width)
setCharWidths(widths)
}, [gradient, allCharsWithSpaces])
const getCharGradientStyle = useCallback(
(globalIndex: number): React.CSSProperties => {
if (!gradient) return {}
const dir = gradient.direction ?? "in oklch to right"
const bg = `linear-gradient(${dir}, ${gradient.from}, ${gradient.to})`
if (charWidths && charWidths.length === allCharsWithSpaces.length) {
const totalWidth = charWidths.reduce((a, b) => a + b, 0)
const offsetBefore = charWidths.slice(0, globalIndex).reduce((a, b) => a + b, 0)
return {
backgroundImage: bg,
backgroundSize: `${totalWidth}px 100%`,
backgroundPosition: `-${offsetBefore}px 0`,
WebkitBackgroundClip: "text",
backgroundClip: "text",
color: "transparent",
}
}
// Fallback using ch units before measurement
const totalChars = allCharsWithSpaces.length
return {
backgroundImage: bg,
backgroundSize: `${totalChars}ch 100%`,
backgroundPosition: `-${globalIndex}ch 0`,
WebkitBackgroundClip: "text",
backgroundClip: "text",
color: "transparent",
}
},
[gradient, charWidths, allCharsWithSpaces],
)
const getStaggerDelay = useCallback(
(index: number, totalChars: number): number => {
const total = totalChars
if (staggerFrom === "first") return index * staggerDuration
if (staggerFrom === "last") return (total - 1 - index) * staggerDuration
if (staggerFrom === "center") {
const center = Math.floor(total / 2)
return Math.abs(center - index) * staggerDuration
}
if (staggerFrom === "random") {
const randomIndex = Math.floor(Math.random() * total)
return Math.abs(randomIndex - index) * staggerDuration
}
return Math.abs((staggerFrom as number) - index) * staggerDuration
},
[staggerFrom, staggerDuration],
)
const handleIndexChange = useCallback(
(newIndex: number) => {
setCurrentTextIndex(newIndex)
if (onNext) onNext(newIndex)
},
[onNext],
)
const next = useCallback(() => {
const nextIndex = currentTextIndex === texts.length - 1 ? (loop ? 0 : currentTextIndex) : currentTextIndex + 1
if (nextIndex !== currentTextIndex) {
handleIndexChange(nextIndex)
}
}, [currentTextIndex, texts.length, loop, handleIndexChange])
const previous = useCallback(() => {
const prevIndex = currentTextIndex === 0 ? (loop ? texts.length - 1 : currentTextIndex) : currentTextIndex - 1
if (prevIndex !== currentTextIndex) {
handleIndexChange(prevIndex)
}
}, [currentTextIndex, texts.length, loop, handleIndexChange])
const jumpTo = useCallback(
(index: number) => {
const validIndex = Math.max(0, Math.min(index, texts.length - 1))
if (validIndex !== currentTextIndex) {
handleIndexChange(validIndex)
}
},
[texts.length, currentTextIndex, handleIndexChange],
)
const reset = useCallback(() => {
if (currentTextIndex !== 0) {
handleIndexChange(0)
}
}, [currentTextIndex, handleIndexChange])
useImperativeHandle(
ref,
() => ({
next,
previous,
jumpTo,
reset,
}),
[next, previous, jumpTo, reset],
)
useEffect(() => {
if (!auto) return
const intervalId = setInterval(next, rotationInterval)
return () => clearInterval(intervalId)
}, [next, rotationInterval, auto])
return (
<motion.span
className={cn("flex flex-wrap whitespace-pre-wrap relative", mainClassName)}
{...rest}
layout
transition={transition}
>
<span className="sr-only">{texts[currentTextIndex]}</span>
{gradient && (
<span
ref={measureRef}
aria-hidden="true"
style={{ position: "absolute", visibility: "hidden", whiteSpace: "pre", pointerEvents: "none" }}
>
{allCharsWithSpaces.map((char, i) => (
<span key={i} data-measure style={{ display: "inline-block" }}>
{char}
</span>
))}
</span>
)}
<AnimatePresence mode={animatePresenceMode} initial={animatePresenceInitial}>
<motion.span
key={currentTextIndex}
className={cn(splitBy === "lines" ? "flex flex-col w-full" : "flex flex-wrap whitespace-pre-wrap relative")}
layout
aria-hidden="true"
>
{elements.map((wordObj, wordIndex, array) => {
const previousCharsCount = array
.slice(0, wordIndex)
.reduce((sum, word) => sum + word.characters.length, 0)
return (
<span key={wordIndex} className={cn("inline-flex", splitLevelClassName)}>
{wordObj.characters.map((char, charIndex) => (
<motion.span
key={charIndex}
initial={initial}
animate={animate}
exit={exit}
transition={{
...transition,
delay: getStaggerDelay(
previousCharsCount + charIndex,
array.reduce((sum, word) => sum + word.characters.length, 0),
),
}}
className={cn("inline-block", elementLevelClassName)}
style={gradient ? getCharGradientStyle(globalIndexMap.get(`${wordIndex}-${charIndex}`) ?? 0) : undefined}
>
{char}
</motion.span>
))}
{wordObj.needsSpace && <span className="whitespace-pre"> </span>}
</span>
)
})}
</motion.span>
</AnimatePresence>
</motion.span>
)
},
)
RotatingText.displayName = "RotatingText"
export default RotatingTexte. g. <RotatingText
texts={["Hello", "こんにちは", "你好"]}
gradient={{ from: "#4ade80", to: "#3b82f6" }}
/>This component applies gradient to each text and thus fixes the issue. Note: This is vibe-coded Component. I don't really know the implementation details but I decided I don't care because it does not involve any business logic. |
Beta Was this translation helpful? Give feedback.

For those who may encounter the same issue, use this component instead.