"use client"
import { useState } from "react"
import { Pause, Play } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
TextFlip,
TextHighlight,
TextReveal,
TextScramble,
} from "@/components/ui/text-animate/text-animate"
import { GridCrosshair } from "@/components/ui/grid-layout/grid-layout"
export default function TextAnimateDemo() {
const [paused, setPaused] = useState(false)
return (
<div className="relative flex min-h-80 w-full min-w-0 flex-col justify-center gap-6 overflow-hidden bg-dots px-6 py-8 sm:min-h-[22rem] sm:px-10">
{/* 蓝紫两团光晕:让关键词的色调有落脚的地方,亮暗模式下都靠 --flow-* 变量取色 */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_88%_6%,color-mix(in_oklab,var(--flow-blue)_20%,transparent),transparent_52%),radial-gradient(ellipse_at_6%_104%,color-mix(in_oklab,var(--flow-violet)_14%,transparent),transparent_50%)]"
/>
<p className="relative flex flex-col items-start gap-3 text-3xl leading-tight font-semibold tracking-tight sm:text-5xl">
<span>把界面写成</span>
<TextFlip
tone="blue"
paused={paused}
words={["组件", "积木", "设计系统"]}
interval={2800}
>
组件、积木或设计系统
</TextFlip>
</p>
<p className="relative max-w-sm text-sm leading-7 text-muted-foreground">
<TextReveal by="word">从一行文字开始,让每个想法都有形状。</TextReveal>
<br />
<TextHighlight tone="amber" className="text-foreground">
少一点重复,多一点创造。
</TextHighlight>
</p>
<div className="relative flex flex-wrap items-center gap-2 pr-10 font-mono text-[10px] text-muted-foreground">
<span>DECODING →</span>
<TextScramble className="text-(--flow-green)" duration={1800}>
构建完成 / READY TO SHIP
</TextScramble>
</div>
<GridCrosshair position="top-right" className="top-5 right-5" />
<GridCrosshair position="bottom-left" className="bottom-5 left-5" />
<Button
variant="outline"
size="icon-sm"
className="absolute right-4 bottom-4 bg-background"
aria-label={paused ? "继续轮换" : "暂停轮换"}
aria-pressed={paused}
onClick={() => setPaused((value) => !value)}
>
{paused ? <Play /> : <Pause />}
</Button>
</div>
)
}
安装
pnpm dlx shadcn@latest add https://yiui.dev/r/text-animate.json用法
四个原语可以单独使用,也可组合成一个标题。服务端直接输出可读文字,动画在客户端增强。
import {
TextFlip,
TextReveal,
TextScramble,
TextHighlight,
} from "@/components/ui/text-animate/text-animate"
export function TextAnimateExample() {
return (
<div>
<h2>
把界面写成 <TextFlip words={["组件", "积木", "系统"]} />
</h2>
<p>
<TextReveal>让每个想法都有形状。</TextReveal>
</p>
<p>
<TextScramble trigger="hover">构建完成</TextScramble>
</p>
<p>
从 <TextHighlight>细节</TextHighlight> 开始。
</p>
</div>
)
}示例
每个示例的标题栏右侧都有「调整」开关:打开后可以实时调整轮换间隔、显现单位、错峰、解码时长、字符集与高亮色调,并开关轮换暂停;每项只作用于当前示例中相关的文字原语;未改动的项显示示例自己的取值。调整时预览画框大小不变。
关键词轮换
胶囊与下划线两种外观。隐藏测量层跟随字体和当前词条尺寸,用 250ms 过渡容器宽度。
"use client"
import { useState } from "react"
import { Pause, Play } from "lucide-react"
import { Button } from "@/components/ui/button"
import { TextFlip } from "@/components/ui/text-animate/text-animate"
export default function TextAnimateFlipDemo() {
const [paused, setPaused] = useState(false)
return (
<div className="flex w-full max-w-md flex-col items-start gap-6 leading-loose font-medium tracking-tight">
<p className="text-2xl">
为 <TextFlip paused={paused} words={["开发者", "设计师", "创造者"]} />{" "}
而造。
</p>
<p className="text-xl">
Build with{" "}
<TextFlip
paused={paused}
words={["care", "clarity", "a little curiosity"]}
variant="underline"
tone="violet"
interval={3200}
/>
.
</p>
<Button
variant="outline"
size="sm"
aria-pressed={paused}
onClick={() => setPaused((value) => !value)}
>
{paused ? (
<Play data-icon="inline-start" />
) : (
<Pause data-icon="inline-start" />
)}
{paused ? "继续轮换" : "暂停轮换"}
</Button>
</div>
)
}
分字与分词显现
Intl.Segmenter 按字素或词切分,保留空格、标点与组合字符。点击按钮重新挂载即可重播。
"use client"
import { useState } from "react"
import { RotateCcw } from "lucide-react"
import { Button } from "@/components/ui/button"
import { TextReveal } from "@/components/ui/text-animate/text-animate"
export default function TextAnimateRevealDemo() {
const [cycle, setCycle] = useState(0)
return (
<div className="flex w-full max-w-md flex-col items-start gap-6">
<p className="text-2xl leading-relaxed font-medium">
<TextReveal key={cycle} trigger="mount">
每一个字,都有自己的入场。
</TextReveal>
</p>
<p className="text-base text-muted-foreground">
<TextReveal key={`en-${cycle}`} by="word" trigger="mount" stagger={95}>
Small details make a lasting impression.
</TextReveal>
</p>
<Button
variant="outline"
size="sm"
onClick={() => setCycle((value) => value + 1)}
>
<RotateCcw data-icon="inline-start" />
重新播放
</Button>
</div>
)
}
乱码解码
字符从乱码逐步落定为目标文字,不模拟打字光标。支持中文目标文本;悬停与键盘聚焦都能重播。
import { TextScramble } from "@/components/ui/text-animate/text-animate"
export default function TextAnimateScrambleDemo() {
return (
<div className="flex w-full max-w-md flex-col items-start gap-5">
<p className="text-xs text-muted-foreground">悬停或聚焦,重新解码</p>
<TextScramble
trigger="hover"
charset="symbols"
className="text-xl font-semibold text-primary sm:text-2xl"
>
DECODING → 构建完成
</TextScramble>
<TextScramble trigger="hover" charset="katakana" className="text-lg">
代码有序,灵感自由。
</TextScramble>
<TextScramble
trigger="hover"
charset="digits"
className="text-sm text-muted-foreground"
>
2026 / READY TO SHIP
</TextScramble>
</div>
)
}
高亮扫过
文字保持可读,只让底部色块从左向右展开。色调使用主题变量,与页面亮暗模式一起变化。
import { TextHighlight } from "@/components/ui/text-animate/text-animate"
export default function TextAnimateHighlightDemo() {
return (
<div className="flex w-full max-w-md flex-col gap-5 leading-loose font-medium">
<p className="text-xl">
好设计,让 <TextHighlight>复杂变简单</TextHighlight>。
</p>
<p className="text-xl">
Keep it{" "}
<TextHighlight tone="violet" variant="underline">
clear & purposeful
</TextHighlight>
.
</p>
<p className="text-base text-muted-foreground">
用 <TextHighlight tone="amber">细节</TextHighlight> 表达态度,用{" "}
<TextHighlight tone="cyan" variant="underline">
留白
</TextHighlight>{" "}
给内容呼吸。
</p>
</div>
)
}
中英混排
中文、英文和日文词条可共用一组轮换。分词显现保留原始文本的空格。
import {
TextFlip,
TextReveal,
TextScramble,
} from "@/components/ui/text-animate/text-animate"
export default function TextAnimateMixedDemo() {
return (
<div className="flex w-full max-w-md flex-col items-start gap-6">
<p className="text-2xl leading-loose font-medium">
你好,
<TextFlip words={["世界", "world", "つくり手"]} variant="underline" />。
</p>
<p className="text-sm leading-7 text-muted-foreground">
<TextReveal by="word">
用 React 构建界面,把 ideas 变成 reality。
</TextReveal>
</p>
<TextScramble characters="界面UI01/" className="text-xs text-primary">
SYSTEM READY / 系统就绪
</TextScramble>
</div>
)
}
API
TextFlip
"pill" | "underline" | null | undefinedstring[]number | undefined胶囊 / 下划线的色调,默认主题强调色。
类型FlowToneName | undefinedboolean | undefined屏幕阅读器的完整替代文案。
类型string | undefinedwords 为词条数组,空数组渲染为空。interval 默认 2600ms,最小 800ms;variant 为 pill(默认)或 underline,tone 换胶囊与下划线的色调(默认 primary,其余同 TextHighlight)。children 可传入屏幕阅读器完整替代文案,否则朗读全部候选词。paused 可暂停轮换。离屏、页面隐藏或系统减少动态效果时停止轮换。
TextReveal
组件内容
类型string"char" | "word" | undefined"in-view" | "mount" | undefined相邻片段的延迟,毫秒。
类型number | undefinedstring | undefinedchildren 为纯文本。by 默认 char,支持 word;locale 默认 zh-CN,影响分词规则。中文逐字用 char,中英混排按词可用 word。trigger 默认 in-view,也可用 mount;每次挂载只播放一次。stagger 默认 35ms,每片段显现持续 500ms。
TextScramble
组件内容
类型string"hover" | "in-view" | "mount" | undefined"chinese" | "digits" | "katakana" | "latin" | "symbols" | undefined覆盖预设字符集;空字符串回退为拉丁字母。
类型string | undefinednumber | undefinedchildren 为最终纯文本。trigger 支持 mount / hover / in-view(默认);duration 默认 1000ms,最小 100ms。charset 支持 latin / digits / symbols / katakana / chinese;characters 传入自定义字符集,空值回退为预设。字符按字素切分,空白不扰动,乱码不参与布局计算。
TextHighlight
"block" | "underline" | null | undefinedFlowToneName | undefined"in-view" | "mount" | undefinedvariant 为 block(默认)或 underline,trigger 为 mount / in-view(默认)。tone 默认 primary,也支持 foreground / muted / destructive / blue / green / amber / rose / violet / cyan;多彩色调来自 --flow-* 变量。可在句子里仅包裹关键词。
调整 Context
TextAnimateTuningContext 接收 TextAnimateTuning 覆盖项;onReport(report: TextAnimateReport) 回报覆盖前的实际属性。 多种原语共用同一覆盖层,回报按属性汇总;同一属性取第一个原语的值。当前示例没有的原语对应控件显示默认值。显现单位或错峰改变时自动重播显现;解码参数改变后,悬停示例需再次悬停或聚焦。
可访问性
- 轮换、分字和乱码组件将动画层设为
aria-hidden,单独提供完整文本,读屏不会逐帧朗读。 - 高亮不拆分原文,按正常文本顺序朗读。颜色不是传达内容的唯一方式。
hover解码也支持键盘聚焦,并提供可见的焦点环。- 开启“减少动态效果”后显示完整文字,不轮换、不扰动、不模糊。无 JS 时同样保留原文和首个轮换词。
- 自动轮换适合短标题;关键信息应在固定正文中另有说明。
注意事项
-
调整面板通过
TextAnimateTuningContext覆盖子树里的interval、by、stagger、duration、charset、tone、paused,并用onReport回报示例自己的取值。不包这一层时组件行为不变;它面向调试与文档,正常使用不需要。 -
显现与高亮由带
href/precedence的去重样式驱动;默认入场前文本也是可见的,避免脚本失败造成空白。 -
TextFlip在当前词上测量,不会为最长词预留固定宽度。请让所在句子可自然换行,避免容器过窄时放入超长且不换行的词条。 -
Intl.Segmenter需要现代浏览器。组件不创建实时播报区域,以免装饰性动画打断阅读。 -
色调复用
flow-diagram,安装时会一并提供相关变量。
源码
"use client"
import * as React from "react"
import { motion, useInView } from "motion/react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import {
flowToneStyle,
type FlowToneName,
} from "@/components/ui/flow-diagram/tone"
import {
useTextTuning,
useTextMotion,
useTextEntrance,
segmentText,
textStyles,
} from "./shared"
import { TextScramble } from "./text-scramble"
import { TextAnimateTuningContext } from "./shared"
export type { TextAnimateTuning, TextAnimateReport } from "./shared"
const textFlipVariants = cva("relative inline-grid max-w-full align-baseline", {
variants: {
variant: {
pill: "rounded-lg border border-edge bg-(--flow-tone)/10 px-2.5 py-0.5 text-(--flow-tone)",
underline: "border-b-2 border-(--flow-tone) px-1 text-(--flow-tone)",
},
},
defaultVariants: { variant: "pill" },
})
function TextFlip({
words,
interval = 2600,
variant,
tone = "primary",
paused = false,
className,
style,
children,
...props
}: Omit<React.ComponentProps<"span">, "children"> &
VariantProps<typeof textFlipVariants> & {
words: string[]
interval?: number
/** 胶囊 / 下划线的色调,默认主题强调色。 */
tone?: FlowToneName
paused?: boolean
/** 屏幕阅读器的完整替代文案。 */
children?: string
}) {
const tuning = useTextTuning({ interval, paused, tone })
interval = tuning.interval ?? interval
paused = tuning.paused ?? paused
tone = tuning.tone ?? tone
const ref = React.useRef<HTMLSpanElement>(null)
const measure = React.useRef<HTMLSpanElement>(null)
const inView = useInView(ref, { amount: 0.2 })
const reduced = useTextMotion()
const [index, setIndex] = React.useState(0)
const [width, setWidth] = React.useState<number>()
const activeIndex = index % Math.max(1, words.length)
const word = words[activeIndex] ?? ""
React.useEffect(() => {
if (paused || reduced || !inView || words.length < 2) return
const timer = window.setInterval(
() => {
if (!document.hidden) setIndex((value) => value + 1)
},
Math.max(800, interval),
)
return () => window.clearInterval(timer)
}, [paused, reduced, inView, interval, words.length])
React.useEffect(() => {
const element = measure.current
if (!element) return
const observer = new ResizeObserver(() =>
setWidth(element.getBoundingClientRect().width),
)
observer.observe(element)
return () => observer.disconnect()
}, [word])
return (
<span
{...props}
ref={ref}
data-slot="text-flip"
data-interval={interval}
data-paused={paused}
data-tone={tone}
className={cn(textFlipVariants({ variant }), className)}
style={{ ...flowToneStyle(tone), ...style }}
>
<span className="sr-only">{children ?? words.join("、")}</span>
<motion.span
aria-hidden
className="relative block overflow-hidden"
animate={{ width: width ?? "auto" }}
transition={{ duration: reduced ? 0 : 0.25 }}
>
<span
ref={measure}
className="pointer-events-none invisible absolute top-0 left-0 whitespace-nowrap"
>
{word}
</span>
<motion.span
key={word}
className="block whitespace-nowrap"
initial={reduced ? false : { opacity: 0, y: ".4em", rotateX: -60 }}
animate={{ opacity: 1, y: "0em", rotateX: 0 }}
transition={{ duration: reduced ? 0 : 0.3 }}
>
{word}
</motion.span>
</motion.span>
</span>
)
}
function TextReveal({
children,
by = "char",
trigger = "in-view",
stagger = 35,
locale = "zh-CN",
className,
...props
}: Omit<React.ComponentProps<"span">, "children"> & {
children: string
by?: "char" | "word"
trigger?: "mount" | "in-view"
/** 相邻片段的延迟,毫秒。 */
stagger?: number
locale?: string
}) {
const tuning = useTextTuning({ by, stagger })
by = tuning.by ?? by
stagger = tuning.stagger ?? stagger
const { ref, active } = useTextEntrance(trigger)
const parts = segmentText(children, by, locale, true)
return (
<span
{...props}
ref={ref}
data-slot="text-reveal"
data-by={by}
data-stagger={stagger}
data-active={active}
className={cn("whitespace-pre-wrap", className)}
>
{textStyles()}
<span className="sr-only">{children}</span>
<span aria-hidden key={by + "-" + stagger}>
{parts.map((part, index) => (
<span
key={index}
data-slot="text-reveal-part"
className={cn(!/^\s+$/.test(part) && "inline-block")}
style={
{
"--text-delay": `${index * Math.max(0, stagger)}ms`,
} as React.CSSProperties
}
>
{part}
</span>
))}
</span>
</span>
)
}
const textHighlightVariants = cva("rounded-sm px-0.5", {
variants: { variant: { block: "", underline: "rounded-none pb-0.5" } },
defaultVariants: { variant: "block" },
})
function TextHighlight({
children,
tone = "primary",
variant = "block",
trigger = "in-view",
className,
style,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof textHighlightVariants> & {
tone?: FlowToneName
trigger?: "mount" | "in-view"
}) {
const tuning = useTextTuning({ tone })
tone = tuning.tone ?? tone
const { ref, active } = useTextEntrance(trigger)
return (
<span
{...props}
ref={ref}
data-slot="text-highlight"
data-tone={tone}
data-variant={variant}
data-active={active}
className={cn(textHighlightVariants({ variant }), className)}
style={
{
...flowToneStyle(tone),
"--text-tone": "var(--flow-tone)",
...style,
} as React.CSSProperties
}
>
{textStyles()}
{children}
</span>
)
}
export {
TextAnimateTuningContext,
TextFlip,
TextReveal,
TextScramble,
TextHighlight,
textFlipVariants,
textHighlightVariants,
}
"use client"
import * as React from "react"
import { useInView } from "motion/react"
import { cn } from "@/lib/utils"
import { segmentText, textStyles, useTextMotion, useTextTuning } from "./shared"
const TEXT_CHARSETS = {
latin: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
digits: "0123456789",
symbols: "!<>_+?/[]{}=",
katakana: "アイウエオカキクケコサシスセソタチツテト",
chinese: "界面组件线条网格构建系统开源设计",
}
type TextScrambleCharset = keyof typeof TEXT_CHARSETS
function TextScramble({
children,
trigger = "in-view",
charset = "latin",
characters,
duration = 1000,
className,
onMouseEnter,
onFocus,
...props
}: Omit<React.ComponentProps<"span">, "children"> & {
children: string
trigger?: "mount" | "hover" | "in-view"
charset?: TextScrambleCharset
/** 覆盖预设字符集;空字符串回退为拉丁字母。 */
characters?: string
duration?: number
}) {
const tuning = useTextTuning({ charset, duration })
charset = tuning.charset ?? charset
duration = tuning.duration ?? duration
// 面板明确选择预设时覆盖示例的自定义字符池。
if (tuning.charset !== undefined) characters = undefined
const ref = React.useRef<HTMLSpanElement>(null)
const inView = useInView(ref, { once: true, amount: 0.2 })
const reduced = useTextMotion()
const [run, setRun] = React.useState(0)
const [frame, setFrame] = React.useState<number | null>(null)
const parts = segmentText(children, "char")
const pool = Array.from(characters || TEXT_CHARSETS[charset])
const active =
!reduced &&
(trigger === "mount" ||
(trigger === "in-view" && inView) ||
(trigger === "hover" && run > 0))
React.useEffect(() => {
if (!active) return
const start = performance.now()
const length = segmentText(children, "char").length
const totalFrames = Math.max(1, length + 8)
const timer = window.setInterval(() => {
const progress = (performance.now() - start) / Math.max(100, duration)
setFrame(progress >= 1 ? null : Math.floor(progress * totalFrames))
if (progress >= 1) window.clearInterval(timer)
}, 40)
return () => window.clearInterval(timer)
}, [active, run, children, duration, charset, characters])
return (
<span
{...props}
ref={ref}
data-slot="text-scramble"
data-duration={duration}
data-charset={charset}
tabIndex={trigger === "hover" ? 0 : props.tabIndex}
className={cn(
"font-mono whitespace-pre-wrap outline-none focus-visible:ring-3 focus-visible:ring-ring/50",
className,
)}
onMouseEnter={(event) => {
onMouseEnter?.(event)
if (trigger === "hover") setRun((value) => value + 1)
}}
onFocus={(event) => {
onFocus?.(event)
if (trigger === "hover") setRun((value) => value + 1)
}}
>
{textStyles()}
<span className="sr-only">{children}</span>
<span aria-hidden>
{parts.map((part, index) => {
const scrambling =
active &&
frame !== null &&
index >= frame - 8 &&
!/^\s+$/.test(part)
return (
<span key={index} className="relative inline-block">
<span
data-slot="text-scramble-final"
className={cn(scrambling && "invisible")}
>
{part}
</span>
{scrambling ? (
<span
data-slot="text-scramble-noise"
className="absolute inset-0 text-center"
>
{pool[(index * 7 + frame * 11) % pool.length]}
</span>
) : null}
</span>
)
})}
</span>
</span>
)
}
export { TextScramble }
export type { TextScrambleCharset }
"use client"
import * as React from "react"
import { useInView } from "motion/react"
import type { FlowToneName } from "@/components/ui/flow-diagram/tone"
import type { TextScrambleCharset } from "./text-scramble"
/** 各文字原语回报自己负责的属性,同一子树按属性保留第一个示例值。 */
type TextAnimateReport = Partial<{
interval: number
paused: boolean
by: "char" | "word"
stagger: number
duration: number
// 用顶层 import type:shadcn CLI 安装时只改写静态 import 的路径,内联 import() 类型里的别名不会被改写
charset: TextScrambleCharset
tone: FlowToneName
}>
type TextAnimateTuning = TextAnimateReport & {
/** 回报覆盖前的示例取值。 */
onReport?: (report: TextAnimateReport) => void
}
const TextAnimateTuningContext = React.createContext<TextAnimateTuning>({})
const reports = new WeakMap<
NonNullable<TextAnimateTuning["onReport"]>,
TextAnimateReport
>()
function useTextTuning(report: TextAnimateReport) {
const tuning = React.useContext(TextAnimateTuningContext)
const { onReport } = tuning
// 效果按原语的属性订阅;不因 Provider 的新对象重复回报。
const { interval, paused, by, stagger, duration, charset, tone } = report
React.useEffect(() => {
if (!onReport) return
const previous = reports.get(onReport)
const next = Object.fromEntries(
Object.entries({
interval,
paused,
by,
stagger,
duration,
charset,
tone,
}).filter(([, value]) => value !== undefined),
)
reports.set(onReport, { ...next, ...previous })
if (!previous) {
// 等四种原语的 effect 都执行后再汇总,兼容面板只接收首次回报。
queueMicrotask(() => {
const collected = reports.get(onReport) ?? {}
reports.delete(onReport)
onReport(collected)
})
}
}, [onReport, interval, paused, by, stagger, duration, charset, tone])
return tuning
}
function subscribe(callback: () => void) {
const media = window.matchMedia("(prefers-reduced-motion: reduce)")
media.addEventListener("change", callback)
return () => media.removeEventListener("change", callback)
}
function snapshot() {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches
}
function serverSnapshot() {
return true
}
function useTextMotion() {
return React.useSyncExternalStore(subscribe, snapshot, serverSnapshot)
}
function useTextEntrance(trigger: "mount" | "in-view") {
const ref = React.useRef<HTMLSpanElement>(null)
const inView = useInView(ref, { once: true, amount: 0.2 })
const reduced = useTextMotion()
return { ref, active: !reduced && (trigger === "mount" || inView), reduced }
}
// 不能出现在行首的标点:每个片段都是 inline-block,浏览器的禁则不再生效,
// 所以把它们并进前一个片段,避免「。」孤零零掉到下一行
const NO_LINE_START = /^[,。、;:!?)」』》”’…·.,;:!?)\]}]+$/u
function segmentText(
text: string,
by: "char" | "word",
locale = "zh-CN",
/** 把行首禁则标点并进前一个片段,只有每个片段都是 inline-block 时才需要 */
glue = false,
) {
const parts: string[] = []
for (const { segment } of new Intl.Segmenter(locale, {
granularity: by === "char" ? "grapheme" : "word",
}).segment(text)) {
if (glue && parts.length && NO_LINE_START.test(segment)) {
parts[parts.length - 1] += segment
} else {
parts.push(segment)
}
}
return parts
}
const TEXT_CSS = `
@keyframes tv-text-reveal { from { opacity:0; filter:blur(6px); transform:translateY(.35em) } to { opacity:1; filter:blur(0); transform:translateY(0) } }
@keyframes tv-text-sweep { from { background-size:0% 100% } to { background-size:100% 100% } }
@media(prefers-reduced-motion:no-preference) {
[data-slot="text-reveal"][data-active="true"] [data-slot="text-reveal-part"] { animation:tv-text-reveal .5s both; animation-delay:var(--text-delay) }
[data-slot="text-highlight"][data-active="true"] { animation:tv-text-sweep .65s both }
}
[data-slot="text-highlight"] { background-image:linear-gradient(color-mix(in oklch,var(--text-tone) 22%,transparent),color-mix(in oklch,var(--text-tone) 22%,transparent)); background-repeat:no-repeat; background-position:0 100%; box-decoration-break:clone; -webkit-box-decoration-break:clone }
[data-slot="text-highlight"][data-variant="underline"] { background-image:linear-gradient(var(--text-tone),var(--text-tone)); background-origin:content-box; background-size:100% .09em }
@keyframes tv-text-underline { from { background-size:0% .09em } to { background-size:100% .09em } }
@media(prefers-reduced-motion:no-preference) { [data-slot="text-highlight"][data-active="true"][data-variant="underline"] { animation-name:tv-text-underline } }
@media(prefers-reduced-motion:reduce) {
[data-slot="text-scramble-final"] { visibility:visible!important }
[data-slot="text-scramble-noise"] { display:none }
}`
function textStyles() {
return React.createElement(
"style",
{ href: "travinsy-text-animate", precedence: "default" },
TEXT_CSS,
)
}
export {
TextAnimateTuningContext,
useTextTuning,
useTextMotion,
useTextEntrance,
segmentText,
textStyles,
}
export type { TextAnimateTuning, TextAnimateReport }