跳至正文
组件

Glyph Field字符矩阵

由主题色字符组成的自适应画布背景,支持混合色调、渐隐遮罩、中心内容与离屏暂停。

正在加载…

安装

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

用法

GlyphField 负责背景,子内容使用普通 DOM,居中叠放在画布之上。高度由外框决定,可用 className="h-screen" 铺满屏幕,默认最小高度为 18rem。

components/security-field.tsx
import { GlyphField } from "@/components/ui/glyph-field/glyph-field"

export function SecurityField() {
  return (
    <GlyphField glyphs="hex" tone={["muted", "primary"]} fade="center" paused>
      <h2>安全访问</h2>
      <p>端到端加密 · 零信任</p>
    </GlyphField>
  )
}

将 paused 设为 false 可播放随机闪烁。持续动画旁应提供暂停按钮,页首的演示展示了受控暂停的做法:按钮的状态直接绑定 paused。glyphs 可以是 latin、binary、hex、katakana、cjk,也可以直接传入任意字符串。字符按 Unicode 码点拆分,不会截断常见汉字。

示例

每个示例的标题栏右侧都有「调整」开关:打开后可以实时调整字符集、渐隐方式、字号、密度、速度与色调,并开关暂停;未改动的项显示示例自己的取值,自定义字符集和混合色调显示为「原样」。调整时容器大小不变,字号和密度只改变画布里的字符。

简洁中心

中心只放一个图标块与标题,适合做背景层上的轻量提示。

正在加载…

中文字符

外层加 dark 让这一块按暗色取色,常用汉字与绿色字符交错排列,fade="center" 给标题留出空位。此处使用静帧;移除 paused 即可闪烁,仍会尊重系统的减少动态效果设置。

正在加载…

二进制横幅

更矮的容器也能自动铺满。density 控制格子占用率,fontSize 控制字符尺寸,fade="none" 保留完整矩阵,文字放在带底色的块里保证可读。

正在加载…

自定义字符集

glyphs 接受任意字符串,按 Unicode 码点拆分。tone 数组里重复某个值可以提高它出现的频率,示例让紫色占一半。

正在加载…

API

GlyphField

  • tone 支持 primary / foreground / muted / destructive / blue / green / amber / rose / violet / cyan、CSS 变量表达式或数组;数组可重复某个色调以提高它的出现频率。
  • density 是 0–1 的占用率;fontSize 最小 8px;speed 是每个逻辑帧的更新比例(0–1,默认 0.04),逻辑帧率为 20fps,0 表示静止。
  • fade="edges" 四周淡出,center 让中心留白,none 保留完整矩阵。遮罩只作用于背景。
  • 空字符集不绘制字符;空色调数组回落到 muted。

可访问性

  • 字符背景默认从读屏隐藏,传 label 后仅背景获得图片语义;子内容始终可被读取,也可以放置链接或按钮。
  • 装饰字符不进入实时播报区域,避免不断打断读屏。
  • paused、离屏、浏览器标签页不可见或系统减少动态效果开启时停止动画。减少动态效果模式保留静态一帧。

注意事项

  • 调整面板通过 GlyphFieldTuningContext 覆盖子树里 GlyphField 的 glyphs、tone、density、fontSize、speed、fade 与 paused,并用 onReport 回报示例自己的取值。不包这一层时组件行为不变;它面向调试与文档,正常使用不需要。
  • 服务端和无 JavaScript 环境输出同尺寸的淡底色占位与完整子内容,画布就绪不会改变布局。
  • 使用 ResizeObserver 跟随容器大小,设备像素比最高 2,单个画布最多 30000 个格子。
  • 只重绘发生变化的格子;颜色在主题、亮暗模式或祖先局部主题改变时重新解析。修改外部样式表时可通过更新 tone 触发重新取色。
  • 色调由 flow-diagram 的 --flow-* 变量提供,安装时一并带入。

源码

components/ui/glyph-field/glyph-field.tsx
"use client"

import * as React from "react"
import { cva } from "class-variance-authority"

import { cn } from "@/lib/utils"
import { FLOW_TONES, type FlowTone } from "@/components/ui/flow-diagram/tone"

const GLYPHS = {
  latin: "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
  binary: "01",
  hex: "0123456789ABCDEF",
  katakana: "アイウエオカキクケコサシスセソタチツテトナニヌネノ",
  cjk: "天地山水风云星月日光万物生长安全数据信息连接世界",
}

type GlyphFieldSet = keyof typeof GLYPHS | (string & {})
type GlyphFieldProps = React.ComponentProps<"div"> & {
  /** 预设名或自定义字符集。@default "latin" */
  glyphs?: GlyphFieldSet
  /** 单色或混合色调,颜色取自容器的主题变量。@default "muted" */
  tone?: FlowTone | FlowTone[]
  /** 格子占用率,范围 0–1。@default 0.8 */
  density?: number
  /** 字号,单位 px,最小 8。@default 14 */
  fontSize?: number
  /** 每个逻辑帧更新的格子比例,范围 0–1,逻辑帧率 20fps。@default 0.04 */
  speed?: number
  /** edges 四周淡出,center 中心留白。@default "edges" */
  fade?: "edges" | "center" | "none"
  paused?: boolean
  /** 只描述字符背景;不会隐藏子内容的可访问性。 */
  label?: string
}

/**
 * 调整用的覆盖项:子树里所有 `GlyphField` 的对应属性被这里的值替换。
 * 用于调试面板一类的场景;不包 `GlyphFieldTuningContext.Provider` 时组件行为不变。
 */
type GlyphFieldTuning = {
  glyphs?: GlyphFieldSet
  tone?: FlowTone | FlowTone[]
  density?: number
  fontSize?: number
  speed?: number
  fade?: "edges" | "center" | "none"
  paused?: boolean
  /** 回报示例自己的实际取值(未被覆盖前),面板据此显示当前值。 */
  onReport?: (values: GlyphFieldReport) => void
}

type GlyphFieldReport = {
  glyphs: GlyphFieldSet
  tone: FlowTone | FlowTone[]
  density: number
  fontSize: number
  speed: number
  fade: "edges" | "center" | "none"
  paused: boolean
}

const GlyphFieldTuningContext = React.createContext<GlyphFieldTuning>({})

const glyphFieldVariants = cva("pointer-events-none absolute inset-0", {
  variants: {
    fade: {
      edges: "[mask-image:radial-gradient(ellipse,black_25%,transparent_85%)]",
      center:
        "[mask-image:radial-gradient(ellipse,transparent_35%,black_100%)]",
      none: "",
    },
  },
  defaultVariants: { fade: "edges" },
})

/** 同尺寸的服务端底色占位与客户端字符画布;子内容保持普通 DOM 语义。 */
function GlyphField({
  className,
  children,
  glyphs: glyphsProp = "latin",
  tone: toneProp = "muted",
  density: densityProp = 0.8,
  fontSize: fontSizeProp = 14,
  speed: speedProp = 0.04,
  fade: fadeProp = "edges",
  paused: pausedProp = false,
  label,
  ...props
}: GlyphFieldProps) {
  const tuning = React.useContext(GlyphFieldTuningContext)
  const { onReport } = tuning
  // 色调数组用值作为依赖,调用者每次传新数组也不会反复回报
  const reportToneKey = JSON.stringify(toneProp)
  React.useEffect(() => {
    onReport?.({
      glyphs: glyphsProp,
      tone: JSON.parse(reportToneKey) as FlowTone | FlowTone[],
      density: densityProp,
      fontSize: fontSizeProp,
      speed: speedProp,
      fade: fadeProp,
      paused: pausedProp,
    })
  }, [
    onReport,
    glyphsProp,
    reportToneKey,
    densityProp,
    fontSizeProp,
    speedProp,
    fadeProp,
    pausedProp,
  ])
  const glyphs = tuning.glyphs ?? glyphsProp
  const tone = tuning.tone ?? toneProp
  const density = tuning.density ?? densityProp
  const fontSize = tuning.fontSize ?? fontSizeProp
  const speed = tuning.speed ?? speedProp
  const fade = tuning.fade ?? fadeProp
  const paused = tuning.paused ?? pausedProp
  const canvasRef = React.useRef<HTMLCanvasElement>(null)
  // 用值作为依赖,避免调用者每次创建新的色调数组而重启画布。
  const toneKey = JSON.stringify(tone)
  React.useEffect(() => {
    const canvas = canvasRef.current
    const host = canvas?.parentElement
    const ctx = canvas?.getContext("2d")
    if (!canvas || !host || !ctx) return
    const alphabet = Array.from(GLYPHS[glyphs as keyof typeof GLYPHS] ?? glyphs)
    if (!alphabet.length) return
    const size = Math.max(8, fontSize)
    const stepX = size * 1.2
    const stepY = size * 1.65
    let columns = 0
    let cells: { glyph: string; color: number; opacity: number }[] = []
    let colors: string[] = []
    let frame = 0
    let last = 0
    let visible = false
    let randomSeed = 421
    const media = matchMedia("(prefers-reduced-motion: reduce)")
    const random = () => {
      randomSeed = (Math.imul(randomSeed, 1664525) + 1013904223) >>> 0
      return randomSeed / 4294967296
    }
    function newCell() {
      return {
        glyph:
          random() <= Math.max(0, Math.min(1, density))
            ? alphabet[Math.floor(random() * alphabet.length)]
            : "",
        color: Math.floor(random() * colors.length),
        opacity: 0.18 + random() * 0.55,
      }
    }
    function paint(index: number) {
      if (!ctx) return
      const x = (index % columns) * stepX
      const y = Math.floor(index / columns) * stepY
      ctx.clearRect(x, y, stepX, stepY)
      const cell = cells[index]
      ctx.globalAlpha = cell.opacity
      ctx.fillStyle = colors[cell.color % colors.length]
      ctx.fillText(cell.glyph, x + stepX / 2, y + stepY / 2)
    }
    function palette() {
      if (!host) return
      const swatch = document.createElement("span")
      swatch.hidden = true
      host.append(swatch)
      const tones = JSON.parse(toneKey) as FlowTone | FlowTone[]
      colors = (
        Array.isArray(tones) ? (tones.length ? tones : ["muted"]) : [tones]
      ).map((value) => {
        swatch.style.color =
          FLOW_TONES[value as keyof typeof FLOW_TONES] ?? value
        return getComputedStyle(swatch).color
      })
      swatch.remove()
      cells.forEach((_, index) => paint(index))
    }
    function resize() {
      if (!host || !canvas || !ctx) return
      const { width, height } = host.getBoundingClientRect()
      const dpr = Math.min(2, window.devicePixelRatio || 1)
      canvas.width = Math.round(width * dpr)
      canvas.height = Math.round(height * dpr)
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
      ctx.font = `${size}px ${getComputedStyle(host).getPropertyValue("--font-geist-mono") || "monospace"}`
      ctx.textAlign = "center"
      ctx.textBaseline = "middle"
      columns = Math.ceil(width / stepX)
      // 限制超大容器的单次工作量。
      cells = Array.from(
        { length: Math.min(30000, columns * Math.ceil(height / stepY)) },
        newCell,
      )
      cells.forEach((_, index) => paint(index))
    }
    function tick(now: number) {
      if (now - last >= 50) {
        const count = Math.ceil(cells.length * Math.max(0, Math.min(1, speed)))
        for (let n = 0; n < count; n++) {
          const index = Math.floor(random() * cells.length)
          if (!cells.length) break
          cells[index] = newCell()
          paint(index)
        }
        last = now
      }
      frame = requestAnimationFrame(tick)
    }
    function sync() {
      cancelAnimationFrame(frame)
      if (visible && !document.hidden && !paused && !media.matches && speed > 0)
        frame = requestAnimationFrame(tick)
    }
    palette()
    resize()
    const resizeObserver = new ResizeObserver(resize)
    resizeObserver.observe(host)
    const intersection = new IntersectionObserver(([entry]) => {
      visible = entry.isIntersecting
      sync()
    })
    intersection.observe(host)
    const mutations = new MutationObserver(palette)
    // 只观察祖先属性,避免画布或探针引起反馈循环,并支持局部主题。
    for (
      let ancestor: Element | null = host;
      ancestor;
      ancestor = ancestor.parentElement
    ) {
      mutations.observe(ancestor, {
        attributes: true,
        attributeFilter: ["class", "data-theme", "style"],
      })
    }
    media.addEventListener("change", sync)
    document.addEventListener("visibilitychange", sync)
    window.addEventListener("resize", resize)
    return () => {
      cancelAnimationFrame(frame)
      resizeObserver.disconnect()
      intersection.disconnect()
      mutations.disconnect()
      media.removeEventListener("change", sync)
      document.removeEventListener("visibilitychange", sync)
      window.removeEventListener("resize", resize)
    }
  }, [glyphs, toneKey, density, fontSize, speed, paused])

  return (
    <div
      data-slot="glyph-field"
      data-paused={paused || undefined}
      className={cn(
        "relative isolate flex min-h-72 w-full items-center justify-center overflow-hidden bg-background",
        className,
      )}
      {...props}
    >
      <div
        data-slot="glyph-field-background"
        role={label ? "img" : undefined}
        aria-label={label}
        aria-hidden={label ? undefined : true}
        className={glyphFieldVariants({ fade })}
      >
        <div
          data-slot="glyph-field-placeholder"
          className="absolute inset-0 bg-muted/20"
        />
        <canvas
          ref={canvasRef}
          data-slot="glyph-field-canvas"
          className="absolute inset-0 size-full"
        />
      </div>
      <div
        data-slot="glyph-field-content"
        className="relative z-10 flex max-w-full flex-col items-center justify-center text-center"
      >
        {children}
      </div>
    </div>
  )
}

export { GlyphField, GlyphFieldTuningContext, glyphFieldVariants }
export type {
  GlyphFieldProps,
  GlyphFieldReport,
  GlyphFieldSet,
  GlyphFieldTuning,
}