跳至正文
组件

Text Animate文字动效

关键词轮换、分字显现、乱码解码与高亮扫过,支持中文与渐进增强。

正在加载…

安装

pnpm dlx shadcn@latest add https://yiui.dev/r/text-animate.json
会连带安装 本站条目 grid-layout、flow-diagram。已存在的文件会询问是否覆盖。

用法

四个原语可以单独使用,也可组合成一个标题。服务端直接输出可读文字,动画在客户端增强。

components/text-animate-example.tsx
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 过渡容器宽度。

正在加载…

分字与分词显现

Intl.Segmenter 按字素或词切分,保留空格、标点与组合字符。点击按钮重新挂载即可重播。

正在加载…

乱码解码

字符从乱码逐步落定为目标文字,不模拟打字光标。支持中文目标文本;悬停与键盘聚焦都能重播。

正在加载…

高亮扫过

文字保持可读,只让底部色块从左向右展开。色调使用主题变量,与页面亮暗模式一起变化。

正在加载…

中英混排

中文、英文和日文词条可共用一组轮换。分词显现保留原始文本的空格。

正在加载…

API

TextFlip

words 为词条数组,空数组渲染为空。interval 默认 2600ms,最小 800ms;variant 为 pill(默认)或 underline,tone 换胶囊与下划线的色调(默认 primary,其余同 TextHighlight)。children 可传入屏幕阅读器完整替代文案,否则朗读全部候选词。paused 可暂停轮换。离屏、页面隐藏或系统减少动态效果时停止轮换。

TextReveal

children 为纯文本。by 默认 char,支持 word;locale 默认 zh-CN,影响分词规则。中文逐字用 char,中英混排按词可用 word。trigger 默认 in-view,也可用 mount;每次挂载只播放一次。stagger 默认 35ms,每片段显现持续 500ms。

TextScramble

children 为最终纯文本。trigger 支持 mount / hover / in-view(默认);duration 默认 1000ms,最小 100ms。charset 支持 latin / digits / symbols / katakana / chinese;characters 传入自定义字符集,空值回退为预设。字符按字素切分,空白不扰动,乱码不参与布局计算。

TextHighlight

variant 为 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,安装时会一并提供相关变量。

源码

components/ui/text-animate/text-animate.tsx
"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,
}
components/ui/text-animate/text-scramble.tsx
"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 }
components/ui/text-animate/shared.ts
"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 }