Rotating Text does not work with gradient text CSS, only in Chrome. #859
Answered
by
Toshimichi0915
Toshimichi0915
asked this question in
help
|
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! |
Answered by
Toshimichi0915
Feb 1, 2026
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. |
0 replies
Answer selected by
Toshimichi0915
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment

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