"use client"
import * as React from "react"
import { LockKeyhole, Pause, Play } from "lucide-react"
import { Button } from "@/components/ui/button"
import { GlyphField } from "@/components/ui/glyph-field/glyph-field"
export default function GlyphFieldDemo() {
const [paused, setPaused] = React.useState(false)
return (
<div
data-slot="glyph-field-demo"
className="relative w-full overflow-hidden rounded-lg"
>
<GlyphField
glyphs="hex"
tone={["muted", "primary", "muted"]}
fade="center"
fontSize={12}
paused={paused}
className="h-[26rem]"
>
<div className="relative mb-5 flex size-32 items-center justify-center text-primary">
<div className="absolute inset-4 rounded-full bg-primary/15 blur-2xl" />
<svg
aria-hidden="true"
viewBox="0 0 120 136"
className="absolute h-full w-28 overflow-visible fill-primary/5 stroke-primary [filter:drop-shadow(0_0_12px_var(--edge))]"
strokeWidth="1.25"
>
<path d="M60 7 105 25v39c0 29-23 48-45 62C38 112 15 93 15 64V25Z" />
<path
d="m60 18 35 15v31c0 22-16 39-35 51-19-12-35-29-35-51V33Z"
strokeDasharray="3 5"
className="stroke-primary/40"
/>
<path
d="M60 0v15m0 106v15M0 68h22m76 0h22"
className="stroke-primary/30"
/>
</svg>
<LockKeyhole
aria-hidden="true"
className="relative size-9"
strokeWidth={1.3}
/>
</div>
<span className="mb-2 font-mono text-[10px] tracking-[0.24em] text-primary">
ACCESS VERIFIED / 200
</span>
<p className="text-3xl font-semibold tracking-tight">安全访问</p>
<p className="mt-3 text-sm text-muted-foreground">
端到端加密 · 零信任
</p>
</GlyphField>
<div className="absolute top-4 right-4">
<Button
className="bg-background/80"
variant="ghost"
size="icon-sm"
aria-label={paused ? "播放字符动画" : "暂停字符动画"}
aria-pressed={paused}
onClick={() => setPaused(!paused)}
>
{paused ? <Play /> : <Pause />}
</Button>
</div>
</div>
)
}
安装
pnpm dlx shadcn@latest add https://yiui.dev/r/glyph-field.json用法
GlyphField 负责背景,子内容使用普通 DOM,居中叠放在画布之上。高度由外框决定,可用 className="h-screen" 铺满屏幕,默认最小高度为 18rem。
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 码点拆分,不会截断常见汉字。
示例
每个示例的标题栏右侧都有「调整」开关:打开后可以实时调整字符集、渐隐方式、字号、密度、速度与色调,并开关暂停;未改动的项显示示例自己的取值,自定义字符集和混合色调显示为「原样」。调整时容器大小不变,字号和密度只改变画布里的字符。
简洁中心
中心只放一个图标块与标题,适合做背景层上的轻量提示。
"use client"
import * as React from "react"
import { LockKeyhole, Pause, Play } from "lucide-react"
import { Button } from "@/components/ui/button"
import { GlyphField } from "@/components/ui/glyph-field/glyph-field"
export default function GlyphFieldIconDemo() {
// 字符会持续闪烁,旁边留一个暂停入口,直接绑定 `paused`
const [paused, setPaused] = React.useState(false)
return (
<div className="relative w-full">
<GlyphField
glyphs="hex"
tone={["muted", "primary", "muted"]}
fade="center"
fontSize={12}
paused={paused}
className="h-96 rounded-lg"
>
<div className="mb-5 flex size-14 items-center justify-center rounded-xl border border-edge bg-background text-primary shadow-sm">
<LockKeyhole className="size-6" aria-hidden="true" />
</div>
<p className="text-3xl font-semibold tracking-tight">安全访问</p>
<p className="mt-3 text-sm text-muted-foreground">
端到端加密 · 零信任
</p>
</GlyphField>
<Button
variant="outline"
size="icon-sm"
className="absolute top-3 right-3 bg-background"
aria-label={paused ? "播放字符动画" : "暂停字符动画"}
aria-pressed={paused}
onClick={() => setPaused(!paused)}
>
{paused ? <Play /> : <Pause />}
</Button>
</div>
)
}
中文字符
外层加 dark 让这一块按暗色取色,常用汉字与绿色字符交错排列,fade="center" 给标题留出空位。此处使用静帧;移除 paused 即可闪烁,仍会尊重系统的减少动态效果设置。
import { GlyphField } from "@/components/ui/glyph-field/glyph-field"
// 外层加 `dark` 让这一块按暗色取色,字符和背景都跟着变
export default function GlyphFieldCjkDemo() {
return (
<GlyphField
glyphs="cjk"
tone={["green", "muted"]}
fade="center"
fontSize={18}
density={0.55}
paused
className="dark h-72 rounded-lg text-foreground"
>
<p className="text-3xl font-medium tracking-widest">万物生长</p>
</GlyphField>
)
}
二进制横幅
更矮的容器也能自动铺满。density 控制格子占用率,fontSize 控制字符尺寸,fade="none" 保留完整矩阵,文字放在带底色的块里保证可读。
import { GlyphField } from "@/components/ui/glyph-field/glyph-field"
export default function GlyphFieldBinaryDemo() {
return (
<GlyphField
glyphs="binary"
tone="primary"
fade="none"
fontSize={16}
density={0.5}
paused
className="min-h-40 rounded-lg"
>
<p className="rounded-md border border-edge bg-background px-5 py-2 font-mono text-sm tracking-wider">
数据始终在路上
</p>
</GlyphField>
)
}
自定义字符集
glyphs 接受任意字符串,按 Unicode 码点拆分。tone 数组里重复某个值可以提高它出现的频率,示例让紫色占一半。
import { GlyphField } from "@/components/ui/glyph-field/glyph-field"
// 任意字符串都可以当字符集;色调数组里重复某个值,可以提高它出现的频率
export default function GlyphFieldCustomDemo() {
return (
<GlyphField
glyphs="{}[]()<>/=+-*;:#"
tone={["violet", "violet", "blue", "muted"]}
fade="edges"
paused
className="min-h-56 rounded-lg"
>
<p className="rounded-md border border-edge bg-background px-4 py-2 font-mono text-sm">
pnpm build
</p>
</GlyphField>
)
}
API
GlyphField
预设名或自定义字符集。@default "latin"
类型GlyphFieldSet | undefined单色或混合色调,颜色取自容器的主题变量。@default "muted"
类型FlowTone[] | FlowTone | undefined格子占用率,范围 0–1。@default 0.8
类型number | undefined字号,单位 px,最小 8。@default 14
类型number | undefined每个逻辑帧更新的格子比例,范围 0–1,逻辑帧率 20fps。@default 0.04
类型number | undefinededges 四周淡出,center 中心留白。@default "edges"
类型"center" | "edges" | "none" | undefinedboolean | undefined只描述字符背景;不会隐藏子内容的可访问性。
类型string | undefinedtone支持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-*变量提供,安装时一并带入。
源码
"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,
}