import { CodeStack, CodeFile } from "@/components/ui/code-stack/code-stack"
const FILES = [
["py", "pipeline.py"],
["go", "server.go"],
["css", "theme.css"],
["ts", "index.ts"],
["json", "registry.json"],
["tsx", "page.tsx"],
["rs", "engine.rs"],
["md", "README.md"],
]
export default function CodeStackDemo() {
return (
<CodeStack>
{FILES.map(([lang, name], index) => (
<CodeFile
key={lang}
lang={lang}
name={name}
seed={index + 17}
active={lang === "json"}
/>
))}
</CodeStack>
)
}
安装
pnpm dlx shadcn@latest add https://yiui.dev/r/code-stack.json用法
CodeStack 负责排列,CodeFile 提供文件窗口、行号、语法骨架与语言角标。卡片可单独使用,不需要客户端 JavaScript。
import { CodeStack, CodeFile } from "@/components/ui/code-stack/code-stack"
export function SourceFiles() {
return (
<CodeStack spread="fan">
<CodeFile lang="ts" name="index.ts" seed={1} />
<CodeFile lang="json" name="registry.json" seed={2} active />
<CodeFile lang="css" name="theme.css" seed={3} />
</CodeStack>
)
}文件按传入顺序排列,active 卡片显示强调色边框并置于上层。建议将激活卡放在序列中间,保留文件名的可见空间。每个直接子节点算作一张卡片,避免用 Fragment 包裹多个文件。
示例
每个示例的标题栏右侧都有「调整」开关:打开后可以实时调整排列与显示上限,并开关悬停散开与激活高亮;未改动的项显示示例自己的取值。调整时预览画框大小不变。
级联
以水平与垂直错位代替旋转,适合配置文件与构建流程的展示。
import { CodeStack, CodeFile } from "@/components/ui/code-stack/code-stack"
export default function CodeStackCascadeDemo() {
return (
<CodeStack spread="cascade" className="max-w-xl">
<CodeFile lang="yaml" name="deploy.yaml" seed={2} />
<CodeFile lang="ts" name="config.ts" seed={7} />
<CodeFile lang="json" name="package.json" seed={9} active />
<CodeFile lang="js" name="build.js" seed={4} />
</CodeStack>
)
}
网格
所有文件平铺,窄屏两列、宽屏四列。文件名和语言角标均可直接读取;卡片高度放不下全部骨架行时,从第一行开始显示,底部裁切。
import { CodeStack, CodeFile } from "@/components/ui/code-stack/code-stack"
export default function CodeStackGridDemo() {
return (
<CodeStack spread="grid" className="max-w-2xl">
<CodeFile lang="tsx" name="layout.tsx" seed={3} />
<CodeFile lang="css" name="globals.css" seed={12} />
<CodeFile lang="json" name="registry.json" seed={7} active />
<CodeFile lang="md" name="CHANGELOG.md" seed={6} />
</CodeStack>
)
}
API
CodeStack
排列方式。@default "fan"
类型CodeStackSpread | undefined悬停或内部控件聚焦时散开。@default true
类型boolean | undefined最多显示的文件数,0 显示空堆叠。@default 8
类型number | undefinedspread 支持 fan / cascade / grid;hoverSpread 默认开启,对扇形与级联生效,网格维持平铺。max 默认 8,超出部分不会渲染;0 输出空容器。扇形和级联默认高度 20rem,可通过 className 调整。
CodeFile
语言缩写,未知语言使用主题强调色。@default "ts"
类型string | undefinedstring | undefined每行的缩进与骨架片段,width 是相对于行宽的百分比。
类型CodeFileLine[] | undefined自动骨架的确定性种子。@default 1
类型number | undefinedboolean | undefinedlang 支持 ts / tsx / js / json / css / md / py / go / rs / yaml;其他值仍显示大写角标,颜色回落到主题强调色。name 默认 file.<lang>。seed 决定自动生成的八行骨架,同一个种子在服务端与客户端生成相同结果。
自定义 lines 时,每行含 indent(百分比,0–60)和 tokens 数组;每个片段含 width(百分比,0–100)和可选的 tone。空数组显示没有代码行的文件窗口。
<CodeFile
lang="json"
name="registry.json"
lines={[
{ tokens: [{ width: 18, tone: "muted" }] },
{
indent: 10,
tokens: [
{ width: 24, tone: "primary" },
{ width: 32, tone: "green" },
],
},
{ tokens: [{ width: 12, tone: "muted" }] },
]}
/>调整 Context
CodeStackTuningContext 接收 CodeStackTuning 覆盖项;onReport(report: CodeStackReport) 回报覆盖前的实际属性。 stableFrame 固定调整期间的画框,不改变图形比例。
可访问性
- 文件名和语言角标是可读取的文本;三点、行号与骨架仅作装饰,读屏不会逐行朗读。
- 悬停散开只改变装饰布局,不隐藏必要信息,也不添加无意义的 Tab 焦点;内部自带控件获得焦点时同样散开。
- 系统开启减少动态效果时保留初始排列并关闭过渡;触摸设备保留初始排列。
- 卡片用于文件示意,不展示实际源码。如需逐一读取完整文件名,使用网格模式。
注意事项
-
调整面板通过
CodeStackTuningContext覆盖子树里的spread、max、hoverSpread、active,并用onReport回报示例自己的取值。不包这一层时组件行为不变;它面向调试与文档,正常使用不需要。 -
扇形与级联的卡片宽度随容器变化,默认最多显示八张,适用于组件封面与空状态插图。
-
全部颜色来自主题 token 和
--flow-*,后者由安装时带入的flow-diagram条目提供。 -
CSS 随组件输出,静态导出与无 JavaScript 环境均可显示并响应悬停。
源码
"use client"
import * as React from "react"
import { cva } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { flowToneStyle, type FlowTone } from "@/components/ui/flow-diagram/tone"
const LANG_TONES: Record<string, FlowTone> = {
ts: "blue",
tsx: "cyan",
js: "amber",
json: "primary",
css: "violet",
md: "muted",
py: "green",
go: "cyan",
rs: "rose",
yaml: "amber",
}
/** 文档调整覆盖项;不提供 Provider 时沿用组件属性。 */
type CodeStackReport = {
spread: CodeStackSpread
hoverSpread: boolean
max: number
active: boolean
}
type CodeStackTuning = Partial<CodeStackReport> & {
/** 固定调整期间的画框。@default false */
stableFrame?: boolean
/** 回报覆盖前的示例取值。 */
onReport?: (report: CodeStackReport) => void
}
const CodeStackTuningContext = React.createContext<CodeStackTuning>({})
type CodeStackSpread = "fan" | "cascade" | "grid"
type CodeStackProps = React.ComponentProps<"div"> & {
/** 排列方式。@default "fan" */
spread?: CodeStackSpread
/** 悬停或内部控件聚焦时散开。@default true */
hoverSpread?: boolean
/** 最多显示的文件数,0 显示空堆叠。@default 8 */
max?: number
}
type CodeFileLine = {
indent?: number
tokens: { width: number; tone?: FlowTone }[]
}
type CodeFileProps = Omit<React.ComponentProps<"div">, "children"> & {
/** 语言缩写,未知语言使用主题强调色。@default "ts" */
lang?: string
name?: string
/** 每行的缩进与骨架片段,width 是相对于行宽的百分比。 */
lines?: CodeFileLine[]
/** 自动骨架的确定性种子。@default 1 */
seed?: number
active?: boolean
}
const codeStackVariants = cva(
"relative isolate w-full [container-type:inline-size]",
{
variants: {
spread: {
fan: "h-80",
cascade: "h-80",
grid: "grid grid-cols-2 gap-4 p-4 sm:grid-cols-4",
},
},
defaultVariants: { spread: "fan" },
},
)
/** 纯 CSS 排列,不为装饰性悬停增加键盘焦点。 */
function CodeStack({
className,
children,
spread: spreadProp = "fan",
hoverSpread: hoverSpreadProp = true,
max: maxProp = 8,
...props
}: CodeStackProps) {
const tuning = React.useContext(CodeStackTuningContext)
const { onReport } = tuning
const spread = tuning.spread ?? spreadProp
const hoverSpread = tuning.hoverSpread ?? hoverSpreadProp
const max = tuning.max ?? maxProp
React.useEffect(() => {
onReport?.({
spread: spreadProp,
hoverSpread: hoverSpreadProp,
max: maxProp,
active: React.Children.toArray(children).some(
(child) =>
React.isValidElement<CodeFileProps>(child) &&
Boolean(child.props.active),
),
})
}, [onReport, spreadProp, hoverSpreadProp, maxProp, children])
const frame = React.useRef<HTMLDivElement>(null)
React.useLayoutEffect(() => {
const element = frame.current
if (!element || !tuning.stableFrame) return
// 保留示例原始高度;网格溢出时在画框内滚动,不挤压卡片。
element.style.height = element.getBoundingClientRect().height + "px"
return () => {
element.style.height = ""
}
}, [tuning.stableFrame])
const files = React.Children.toArray(children).slice(
0,
Math.max(0, Math.floor(max)),
)
return (
<div
ref={frame}
data-slot="code-stack"
data-spread={spread}
data-hover-spread={hoverSpread || undefined}
className={cn(
codeStackVariants({ spread }),
tuning.stableFrame && "content-start overflow-auto",
className,
)}
{...props}
>
<style href="code-stack-layout" precedence="default">{`
[data-slot="code-stack"] > [data-slot="code-stack-position"] { transition: transform 280ms cubic-bezier(.2,.8,.2,1); }
[data-slot="code-stack"][data-spread="fan"] > [data-slot="code-stack-position"] {
position: absolute; top: 50%; left: 50%; width: clamp(6.5rem, 25cqw, 10rem);
transform: translate(calc(-50% + var(--code-offset) * min(7cqw, 2.5rem)), calc(-48% + var(--code-curve) * 4px)) rotate(calc(var(--code-offset) * 9deg));
}
[data-slot="code-stack"][data-spread="cascade"] > [data-slot="code-stack-position"] {
position: absolute; top: 50%; left: 50%; width: clamp(7rem, 30cqw, 11rem);
transform: translate(calc(-50% + var(--code-offset) * min(7cqw, 2.2rem)), calc(-50% + var(--code-offset) * 20px));
}
@media (hover: hover) and (prefers-reduced-motion: no-preference) {
[data-slot="code-stack"][data-hover-spread][data-spread="fan"]:hover > [data-slot="code-stack-position"],
[data-slot="code-stack"][data-hover-spread][data-spread="fan"]:focus-within > [data-slot="code-stack-position"] {
transform: translate(calc(-50% + var(--code-offset) * min(9cqw, 3.2rem)), calc(-55% + var(--code-curve) * 5px)) rotate(calc(var(--code-offset) * 13deg));
}
[data-slot="code-stack"][data-hover-spread][data-spread="cascade"]:hover > [data-slot="code-stack-position"],
[data-slot="code-stack"][data-hover-spread][data-spread="cascade"]:focus-within > [data-slot="code-stack-position"] {
transform: translate(calc(-50% + var(--code-offset) * min(10cqw, 3rem)), calc(-50% + var(--code-offset) * 28px));
}
}
@media (prefers-reduced-motion: reduce) {
[data-slot="code-stack"] > [data-slot="code-stack-position"] { transition: none; }
}
`}</style>
{files.map((file, index) => {
const offset = index - (files.length - 1) / 2
return (
<div
key={React.isValidElement(file) ? (file.key ?? index) : index}
data-slot="code-stack-position"
className="relative min-w-0 has-data-active:z-20"
style={
{
"--code-offset": offset,
"--code-curve": offset * offset,
} as React.CSSProperties
}
>
{file}
</div>
)
})}
</div>
)
}
function skeleton(seed: number): CodeFileLine[] {
let value = seed >>> 0
const random = () => {
value = (Math.imul(value, 1664525) + 1013904223) >>> 0
return value / 4294967296
}
return Array.from({ length: 8 }, (_, index) => ({
indent: index === 0 || index === 7 ? 0 : 8,
tokens: Array.from(
{ length: 2 + Math.floor(random() * 2) },
(_, token) => ({
width: 10 + Math.floor(random() * 16),
tone: token === 0 ? "muted" : token === 1 ? undefined : "green",
}),
),
}))
}
/** 代码的示意卡片;骨架不是实际源码,文件名仍然能被读屏读取。 */
function CodeFile({
className,
lang = "ts",
name,
lines,
seed = 1,
active = false,
style,
...props
}: CodeFileProps) {
const tuning = React.useContext(CodeStackTuningContext)
active = tuning.active ?? active
const language = lang.toLowerCase()
const rows = lines ?? skeleton(seed)
return (
<div
data-slot="code-file"
data-active={active || undefined}
className={cn(
"relative flex aspect-[4/5] w-full min-w-0 flex-col overflow-hidden rounded-lg border border-edge bg-card text-card-foreground shadow-sm data-active:border-primary/60 data-active:ring-2 data-active:ring-primary/10",
className,
)}
style={{ ...flowToneStyle(LANG_TONES[language] ?? "primary"), ...style }}
{...props}
>
<div
data-slot="code-file-header"
className="flex min-w-0 flex-wrap items-center gap-1 border-b border-edge px-3 py-2.5"
>
<span
aria-hidden="true"
className="size-1.5 shrink-0 rounded-full bg-(--flow-rose)"
/>
<span
aria-hidden="true"
className="size-1.5 shrink-0 rounded-full bg-(--flow-amber)"
/>
<span
aria-hidden="true"
className="size-1.5 shrink-0 rounded-full bg-(--flow-green)"
/>
<span
className="mt-1 basis-full truncate font-mono text-[9px] text-muted-foreground"
title={name}
>
{name ?? `file.${lang}`}
</span>
</div>
<div
data-slot="code-file-lines"
aria-hidden="true"
className="flex min-h-0 flex-1 flex-col justify-center-safe gap-2 overflow-hidden px-3 py-3"
>
{rows.map((line, index) => (
<div key={index} className="flex h-2 shrink-0 items-center gap-2">
<span className="w-2 shrink-0 text-right font-mono text-[8px] text-muted-foreground">
{index + 1}
</span>
<div
className="flex min-w-0 flex-1 items-center gap-1"
style={{
paddingLeft: `${Math.max(0, Math.min(60, line.indent ?? 0))}%`,
}}
>
{line.tokens.map((token, part) => (
<span
key={part}
className="h-1 shrink-0 rounded-full bg-(--flow-tone)/45"
style={{
width: `${Math.max(0, Math.min(100, token.width))}%`,
...(token.tone ? flowToneStyle(token.tone) : {}),
}}
/>
))}
</div>
</div>
))}
</div>
<div
data-slot="code-file-footer"
className="flex items-end justify-between gap-2 px-3 pb-3"
>
<span
aria-hidden="true"
className="font-mono text-[10px] text-muted-foreground/60"
>
{"</>"}
</span>
<span className="rounded-md border border-edge bg-(--flow-tone)/10 px-1.5 py-0.5 font-mono text-[10px] font-semibold text-(--flow-tone)">
{language.toUpperCase()}
</span>
</div>
</div>
)
}
export { CodeStackTuningContext, CodeStack, CodeFile, codeStackVariants }
export type {
CodeStackTuning,
CodeStackReport,
CodeStackProps,
CodeStackSpread,
CodeFileProps,
CodeFileLine,
}