import {
Bot,
Braces,
Cloud,
Database,
GitBranch,
Globe,
Layers,
Palette,
Rocket,
Server,
Shield,
Smartphone,
Sparkles,
Terminal,
Workflow,
Zap,
} from "lucide-react"
import {
ShuffleGrid,
type ShuffleGridItem,
} from "@/components/ui/shuffle-grid/shuffle-grid"
// 色调对应技能类别,格子点亮时图标换成类别色,下方图例与之对应
const LEGEND = [
{ tone: "blue", label: "前端" },
{ tone: "green", label: "后端" },
{ tone: "cyan", label: "基础设施" },
{ tone: "violet", label: "AI" },
{ tone: "amber", label: "工程" },
] as const
const POOL = [
{ Icon: Braces, tone: "blue" },
{ Icon: Palette, tone: "blue" },
{ Icon: Smartphone, tone: "blue" },
{ Icon: Layers, tone: "blue" },
{ Icon: Database, tone: "green" },
{ Icon: Server, tone: "green" },
{ Icon: Cloud, tone: "cyan" },
{ Icon: Globe, tone: "cyan" },
{ Icon: Shield, tone: "cyan" },
{ Icon: Sparkles, tone: "violet" },
{ Icon: Bot, tone: "violet" },
{ Icon: Workflow, tone: "violet" },
{ Icon: GitBranch, tone: "amber" },
{ Icon: Terminal, tone: "amber" },
{ Icon: Rocket, tone: "amber" },
{ Icon: Zap, tone: "amber" },
] as const
const SKILLS: ShuffleGridItem[] = POOL.map(({ Icon, tone }, index) => ({
id: `skill-${index}`,
tone,
content: <Icon strokeWidth={1.6} className="size-4" />,
}))
// 技术栈墙:十六枚图标轮流上墙,每一步只动一两个格子,同时有几枚按类别色亮起
export default function ShuffleGridDemo() {
return (
<div className="relative flex w-full items-center justify-center">
<div
aria-hidden="true"
className="pointer-events-none absolute -inset-4 bg-dots [mask-image:radial-gradient(ellipse_at_center,black_20%,transparent_72%)] opacity-70"
/>
<div className="relative flex w-full max-w-[17rem] flex-col gap-3 rounded-lg border border-edge bg-card p-3.5 shadow-xs">
<div className="flex items-baseline justify-between gap-3">
<p className="text-xs font-medium">技术栈</p>
<p className="flex items-center gap-1.5 font-mono text-[11px] text-muted-foreground">
<span
aria-hidden="true"
className="size-1.5 rounded-full bg-(--flow-green) motion-safe:animate-pulse"
/>
16 项 · 随机点亮
</p>
</div>
<ShuffleGrid
label="十六个技术图标在方格里随机出现、换位并按类别点亮"
rows={3}
cols={4}
items={SKILLS}
activeCount={10}
litCount={5}
interval={1100}
/>
<ul className="flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-muted-foreground">
{LEGEND.map(({ tone, label }) => (
<li key={tone} className="flex items-center gap-1.5">
<span
aria-hidden="true"
className="size-1.5 rounded-full"
style={{ backgroundColor: `var(--flow-${tone})` }}
/>
{label}
</li>
))}
</ul>
</div>
</div>
)
}
安装
pnpm dlx shadcn@latest add https://yiui.dev/r/shuffle-grid.json用法
items 是条目池,每个条目有唯一的 id、可选的 content(图标、头像、任意节点)和 tone。rows、cols 决定盘面大小,activeCount 决定同时在场几个。组件每隔 interval 毫秒走一步:补一个、拿掉一个、换个格子或两个互换,每步只动一两个格子,画面始终是渐变的。
import { Braces, Database, Globe } from "lucide-react"
import { ShuffleGrid } from "@/components/ui/shuffle-grid/shuffle-grid"
const ITEMS = [
{ id: "ts", tone: "blue", content: <Braces /> },
{ id: "db", tone: "green", content: <Database /> },
{ id: "web", tone: "violet", content: <Globe /> },
]
export function ShuffleGridExample() {
return (
<ShuffleGrid
label="技术图标在方格里随机出现并点亮"
rows={2}
cols={3}
items={ITEMS}
activeCount={2}
/>
)
}随机数来自 seed,同一个种子在服务端与浏览器的初始画面一致,演示数据也就确定;换一个种子得到另一张初始画面。
示例
每个示例的标题栏右侧都有「调整」开关:打开后可以实时调整换位间隔、在场数、点亮数与盘面外观,并开关暂停;未改动的项显示示例自己的取值。调整时盘面的宽高比固定为打开时的样子,切换盘面外观(间距不同)后容器大小不变。有分类的示例由分类决定谁在场,「在场数」不起作用。
团队分配
给 groups 传分类,格子下方出现可横向滚动的胶囊,点击或自动轮播都会切换分类,格子里的条目随之换位。同一个人属于多个分类时是滑动换位,不是消失再出现。头像用 ShuffleGridAvatar,只显示首字,不使用真人照片。
import {
ShuffleGrid,
ShuffleGridAvatar,
type ShuffleGridGroup,
type ShuffleGridItem,
} from "@/components/ui/shuffle-grid/shuffle-grid"
// 全是虚构的名字,头像只用首字
const PEOPLE = [
{ id: "mia", name: "Mia", tone: "blue" },
{ id: "zhou", name: "周屿", tone: "violet" },
{ id: "noah", name: "Noah", tone: "cyan" },
{ id: "chen", name: "陈默", tone: "green" },
{ id: "kai", name: "Kai", tone: "amber" },
{ id: "su", name: "苏晴", tone: "rose" },
{ id: "leo", name: "Leo", tone: "blue" },
{ id: "he", name: "何川", tone: "violet" },
{ id: "eva", name: "Eva", tone: "cyan" },
] as const
const MEMBERS: ShuffleGridItem[] = PEOPLE.map((person) => ({
id: person.id,
tone: person.tone,
content: <ShuffleGridAvatar name={person.name} className="size-9" />,
}))
// 同一个人会出现在几个分类里,切换分类时他们是换位,不是消失再出现
const TEAMS: ShuffleGridGroup[] = [
{ id: "frontend", label: "前端", items: ["mia", "zhou", "kai", "su", "eva"] },
{ id: "design", label: "设计", items: ["su", "noah", "mia"] },
{
id: "backend",
label: "后端",
items: ["chen", "leo", "he", "kai", "eva", "noah"],
},
{ id: "ops", label: "运维", items: ["he", "leo", "zhou"] },
]
export default function ShuffleGridTeamDemo() {
return (
<ShuffleGrid
label="团队成员的首字母头像,按前端、设计、后端、运维分类在方格中换位"
variant="tiles"
items={MEMBERS}
groups={TEAMS}
litCount="all"
groupInterval={3200}
groupsLabel="团队分类"
className="max-w-xs"
/>
)
}
像素块
不传 content,条目就是一块纯色方块;variant="pixels" 把格子收紧成像素风,interval 调小让它快速出没。
import {
ShuffleGrid,
type ShuffleGridItem,
} from "@/components/ui/shuffle-grid/shuffle-grid"
const TONES = ["blue", "violet", "cyan", "green", "amber", "rose"] as const
// 不放内容:每个条目就是一块纯色方块,数量比格子少,在 12×6 的盘面上快速出没
const BLOCKS: ShuffleGridItem[] = Array.from({ length: 30 }, (_, index) => ({
id: `block-${index}`,
tone: TONES[index % TONES.length],
}))
export default function ShuffleGridPixelDemo() {
return (
<ShuffleGrid
label="彩色像素块在 12 列 6 行的方格里快速出现并消失"
variant="pixels"
rows={6}
cols={12}
items={BLOCKS}
activeCount={26}
litCount={12}
interval={420}
seed={5}
className="max-w-xl"
/>
)
}
API
ShuffleGrid
variant 有三种外观:lines 共享 1px 分隔线的整块网格(默认),tiles 独立的圆角格,pixels 紧凑的纯色像素块。litCount 控制同时点亮几个,点亮的条目用自己的 tone,其余是中性色;传 "all" 全部点亮。
分类相关:value / defaultValue / onValueChange 以分类 id 同时支持受控与非受控;groupInterval 设为 0 关闭自动轮播,用户手动选过后会多等一轮再继续。
行数
类型number | undefined3列数
类型number | undefined3条目池,数量可以比格子多,多出来的会轮流出场
类型ShuffleGridItem[]同时在场的条目数;默认约六成的格子
类型number | undefined同时点亮的条目数,"all" 表示全部点亮;默认约三成在场条目
number | "all" | undefined在场条目的 35%每一步的间隔(毫秒)
类型number | undefined1600随机种子:同一个种子,服务端与浏览器的初始画面一致
类型number | undefined1外观
类型ShuffleGridVariant | undefined"lines"条目的默认色调
类型FlowTone | undefined"primary"暂停洗牌与分类轮播
类型boolean | undefinedfalse分类:传入后在格子下方显示胶囊,切换分类时格子里的条目随之换位
类型ShuffleGridGroup[] | undefined受控的当前分类 id
类型string | undefined非受控的初始分类 id,默认第一个
类型string | undefined分类变化时回调(点击和自动轮播都会触发)
类型((id: string) => void) | undefined自动轮播分类的间隔(毫秒);设为 0 则不自动轮播
类型number | undefined3600图示说明;不传时格子作为装饰隐藏
类型string | undefined胶囊行的读屏名称
类型string | undefined"分类"ShuffleGridItem 的字段:id、content、tone;ShuffleGridGroup 的字段:id、label、items(条目 id 的数组,数量不能超过格子数)。
ShuffleGridAvatar
首字母头像:圆底加一个字,亮起时换成条目的色调。放进 content,大小随格子自适应,也可以用 className 指定。
姓名,取第一个字(拉丁字母转大写)作首字母
类型string可访问性
- 盘面默认作为装饰隐藏;传
label后以图片语义朗读概括说明。内容是随机变化的,不会逐个朗读。 - 分类胶囊是真正的按钮,用
aria-pressed表达选中状态,Tab 可达,焦点环可见;读屏名称用groupsLabel改。 - 系统开启「减少动态效果」时不洗牌、不自动轮播,格子停在初始画面;手动点胶囊时直接切换,没有滑动。
- 离开视口或
paused时停止所有定时器。
注意事项
- 调整面板通过
ShuffleGridTuningContext覆盖子树里ShuffleGrid的interval、activeCount、litCount、variant与paused,并用onReport回报示例自己的取值;stableFrame让盘面按打开时的宽高比铺满,切换variant不改变容器大小。不包这一层时组件行为不变;它面向调试与文档,正常使用不需要。 - 同一条目在分类间换位用的是
motion的共享布局动画,条目id在同一个盘面里必须唯一。 - 格子是正方形(
aspect-square),盘面宽度由外层决定;cols很大时单格会变小,图标大小可以通过content自行指定。 - 点亮的色调来自
--flow-*变量,依赖flow-diagram条目,安装时会一并提供。
源码
"use client"
import * as React from "react"
import {
AnimatePresence,
motion,
useInView,
useReducedMotion,
} from "motion/react"
import { cn } from "@/lib/utils"
import { useControllableState } from "@/hooks/use-controllable-state"
import { flowToneStyle, type FlowTone } from "@/components/ui/flow-diagram/tone"
import {
arrangeSlots,
createRandom,
initialSlots,
pickLit,
stepSlots,
type Slots,
} from "./engine"
import { ShuffleGridPills } from "./pills"
type ShuffleGridItem = {
/** 唯一标识,分类里用它引用条目 */
id: string
/** 格子里的内容:图标、首字母头像、任意节点。不传则是一块纯色方块 */
content?: React.ReactNode
/** 这个条目点亮时的色调,不传则用 `tone` */
tone?: FlowTone
}
type ShuffleGridGroup = {
id: string
/** 胶囊上的文字 */
label: string
/** 这个分类里的条目 id,数量不能超过格子数 */
items: string[]
}
/** `lines` 共享 1px 分隔线的整块网格;`tiles` 独立圆角格;`pixels` 紧凑的纯色像素块。 */
type ShuffleGridVariant = "lines" | "tiles" | "pixels"
const BOARD: Record<ShuffleGridVariant, string> = {
lines: "gap-px overflow-hidden rounded-lg border border-edge bg-edge",
tiles: "gap-2.5",
pixels: "gap-1",
}
const CELL: Record<ShuffleGridVariant, string> = {
lines: "bg-card",
tiles: "rounded-xl border border-dashed border-edge",
pixels: "rounded-[3px] bg-muted/40",
}
const TILE: Record<ShuffleGridVariant, string> = {
lines:
"text-muted-foreground data-lit:bg-(--flow-tone)/10 data-lit:text-(--flow-tone)",
tiles:
"rounded-xl border border-edge bg-muted/40 text-muted-foreground shadow-xs data-lit:border-(--flow-tone)/35 data-lit:bg-(--flow-tone)/12 data-lit:text-(--flow-tone)",
pixels: "rounded-[3px] bg-(--flow-tone)/25 data-lit:bg-(--flow-tone)",
}
/**
* 调整用的覆盖项:子树里所有 `ShuffleGrid` 的对应属性被这里的值替换。
* 用于调试面板一类的场景;不包 `ShuffleGridTuningContext.Provider` 时组件行为不变。
*/
type ShuffleGridTuning = {
interval?: number
activeCount?: number
litCount?: number | "all"
variant?: ShuffleGridVariant
paused?: boolean
/** 固定盘面外框的宽高比为打开时的样子,切换 `variant` 时容器大小不变。 */
stableFrame?: boolean
/** 回报示例自己的实际取值(未被覆盖前),面板据此显示当前值。 */
onReport?: (values: ShuffleGridReport) => void
}
type ShuffleGridReport = {
interval: number
/** 同时在场的条目数(已按格子数与条目数截断)。 */
activeCount: number
/** 同时点亮的条目数;`"all"` 换算成当前格子数。 */
litCount: number
variant: ShuffleGridVariant
paused: boolean
}
const ShuffleGridTuningContext = React.createContext<ShuffleGridTuning>({})
type State = { slots: Slots; lit: number[]; tick: number }
type Action =
| { type: "tick"; shuffle: boolean }
| { type: "arrange"; target: number[] }
| { type: "retarget" }
type ShuffleGridProps = Omit<
React.ComponentProps<"div">,
"children" | "defaultValue" | "onChange"
> & {
/** 行数 @default 3 */
rows?: number
/** 列数 @default 3 */
cols?: number
/** 条目池,数量可以比格子多,多出来的会轮流出场 */
items: ShuffleGridItem[]
/** 同时在场的条目数;默认约六成的格子 */
activeCount?: number
/**
* 同时点亮的条目数,`"all"` 表示全部点亮;默认约三成在场条目
* @default 在场条目的 35%
*/
litCount?: number | "all"
/**
* 每一步的间隔(毫秒)
* @default 1600
*/
interval?: number
/**
* 随机种子:同一个种子,服务端与浏览器的初始画面一致
* @default 1
*/
seed?: number
/**
* 外观
* @default "lines"
*/
variant?: ShuffleGridVariant
/**
* 条目的默认色调
* @default "primary"
*/
tone?: FlowTone
/**
* 暂停洗牌与分类轮播
* @default false
*/
paused?: boolean
/** 分类:传入后在格子下方显示胶囊,切换分类时格子里的条目随之换位 */
groups?: ShuffleGridGroup[]
/** 受控的当前分类 id */
value?: string
/** 非受控的初始分类 id,默认第一个 */
defaultValue?: string
/** 分类变化时回调(点击和自动轮播都会触发) */
onValueChange?: (id: string) => void
/**
* 自动轮播分类的间隔(毫秒);设为 0 则不自动轮播
* @default 3600
*/
groupInterval?: number
/** 图示说明;不传时格子作为装饰隐藏 */
label?: string
/** 胶囊行的读屏名称 @default "分类" */
groupsLabel?: string
}
function buildState(props: {
cells: number
items: ShuffleGridItem[]
active: number
lit: number | "all"
seed: number
target?: number[]
}): State {
const random = createRandom(props.seed)
const slots = props.target
? arrangeSlots(Array(props.cells).fill(null), props.target, random)
: initialSlots(props.cells, props.items.length, props.active, random)
const count = props.lit === "all" ? slots.length : props.lit
return { slots, lit: pickLit(slots, count, random), tick: 0 }
}
/** 方格里的内容随机出现、换位、点亮;也可以按分类整体换位,像一面会呼吸的能力墙或团队指派板。 */
function ShuffleGrid({
className,
rows = 3,
cols = 3,
items,
activeCount: activeCountProp,
litCount: litCountProp,
interval: intervalProp = 1600,
seed = 1,
variant: variantProp = "lines",
tone = "primary",
paused: pausedProp = false,
groups,
value,
defaultValue,
onValueChange,
groupInterval = 3600,
label,
groupsLabel,
...props
}: ShuffleGridProps) {
const uid = React.useId()
const ref = React.useRef<HTMLDivElement>(null)
const boardRef = React.useRef<HTMLDivElement>(null)
const inView = useInView(ref, { amount: 0.3 })
const reduced = !!useReducedMotion()
const tuning = React.useContext(ShuffleGridTuningContext)
const { onReport, stableFrame } = tuning
const interval = tuning.interval ?? intervalProp
const variant = tuning.variant ?? variantProp
const paused = tuning.paused ?? pausedProp
const cells = Math.max(1, Math.floor(rows)) * Math.max(1, Math.floor(cols))
const idIndex = React.useMemo(
() => new Map(items.map((item, index) => [item.id, index])),
[items],
)
const targetOf = React.useCallback(
(id: string) =>
groups
?.find((group) => group.id === id)
?.items.flatMap((itemId) => idIndex.get(itemId) ?? [])
.slice(0, cells),
[groups, idIndex, cells],
)
const propActive = Math.min(
cells,
items.length,
activeCountProp ?? Math.round(cells * 0.6),
)
const active = Math.min(cells, items.length, tuning.activeCount ?? propActive)
const propLit: number | "all" =
litCountProp ?? Math.max(1, Math.round(propActive * 0.35))
const lit: number | "all" =
tuning.litCount ?? litCountProp ?? Math.max(1, Math.round(active * 0.35))
const reportedLit = propLit === "all" ? cells : propLit
React.useEffect(() => {
onReport?.({
interval: intervalProp,
activeCount: propActive,
litCount: reportedLit,
variant: variantProp,
paused: pausedProp,
})
}, [onReport, intervalProp, propActive, reportedLit, variantProp, pausedProp])
const [group, setGroup] = useControllableState({
prop: value,
defaultProp: defaultValue ?? groups?.[0]?.id ?? "",
onChange: onValueChange,
})
const [state, dispatch] = React.useReducer(
(prev: State, action: Action): State => {
const random = createRandom(seed * 7919 + (prev.tick + 1) * 104729)
let slots = prev.slots
if (action.type === "arrange") {
slots = arrangeSlots(slots, action.target, random)
} else if (action.type === "retarget") {
// 在场数量变了:一次补齐 / 拿掉,不必等一格一格地换
for (let n = 0; n < cells; n++) {
if (slots.filter((v) => v !== null).length === active) break
slots = stepSlots(slots, items.length, active, random)
}
} else if (action.shuffle) {
slots = stepSlots(slots, items.length, active, random)
}
const count = lit === "all" ? slots.length : lit
return { slots, lit: pickLit(slots, count, random), tick: prev.tick + 1 }
},
undefined,
() =>
buildState({
cells,
items,
active,
lit,
seed,
target: groups ? targetOf(group) : undefined,
}),
)
// 在场数、点亮数变了就立刻重排,不等下一个定时步;分类决定谁在场时只重新挑点亮的
const settled = React.useRef({ active, lit })
React.useEffect(() => {
const prev = settled.current
if (prev.active === active && prev.lit === lit) return
settled.current = { active, lit }
dispatch(
!groups && prev.active !== active
? { type: "retarget" }
: { type: "tick", shuffle: false },
)
}, [active, lit, groups])
// 切换分类:把格子排成新分类的样子(首次渲染已经按初始分类排好,不重复)
const arranged = React.useRef(group)
React.useEffect(() => {
if (!groups || arranged.current === group) return
arranged.current = group
const target = targetOf(group)
if (target) dispatch({ type: "arrange", target })
}, [groups, group, targetOf])
const running = inView && !paused && !reduced
// 有分类时由分类决定谁在场,定时器只负责换一批点亮的;没有分类时才真正洗牌
React.useEffect(() => {
if (!running) return
const id = window.setInterval(
() => dispatch({ type: "tick", shuffle: !groups }),
Math.max(200, interval),
)
return () => window.clearInterval(id)
}, [running, interval, groups])
// 分类自动轮播;用户手动选过之后多等一轮,不抢操作
const holdUntil = React.useRef(0)
React.useEffect(() => {
if (!groups || groups.length < 2 || !groupInterval || !running) return
const wait = Math.max(groupInterval, holdUntil.current - Date.now())
const id = window.setTimeout(() => {
const index = groups.findIndex((item) => item.id === group)
setGroup(groups[(index + 1) % groups.length].id)
}, wait)
return () => window.clearTimeout(id)
}, [groups, group, groupInterval, running, setGroup])
// 固定外框:记下打开时盘面的宽高比,之后换 `variant`(间距不同)盘面大小也不变
const [frameRatio, setFrameRatio] = React.useState<number>()
React.useEffect(() => {
const rect = boardRef.current?.getBoundingClientRect()
setFrameRatio(
stableFrame && rect?.height ? rect.width / rect.height : undefined,
)
}, [stableFrame])
const litSet = new Set(state.lit)
const transition = reduced
? { duration: 0 }
: { type: "spring" as const, stiffness: 380, damping: 32 }
return (
<div
{...props}
ref={ref}
data-slot="shuffle-grid"
data-variant={variant}
className={cn("flex w-full flex-col gap-4", className)}
>
<div
ref={boardRef}
data-slot="shuffle-grid-board"
role={label ? "img" : undefined}
aria-label={label}
aria-hidden={label ? undefined : true}
className={cn("grid", BOARD[variant])}
style={{
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
...(frameRatio
? {
gridTemplateRows: `repeat(${Math.max(1, Math.floor(rows))}, minmax(0, 1fr))`,
aspectRatio: frameRatio,
}
: null),
}}
>
{state.slots.map((slot, cell) => {
const item = slot === null ? undefined : items[slot]
return (
<div
key={cell}
data-slot="shuffle-grid-cell"
className={cn(
"relative",
!frameRatio && "aspect-square",
CELL[variant],
)}
>
<AnimatePresence initial={false}>
{item ? (
<motion.div
key={item.id}
layoutId={`${uid}-${item.id}`}
initial={{ opacity: 0, scale: 0.6 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.6 }}
transition={transition}
className="absolute inset-0"
>
<div
data-slot="shuffle-grid-tile"
data-lit={litSet.has(slot as number) || undefined}
className={cn(
"group/tile flex size-full items-center justify-center transition-colors duration-500 motion-reduce:transition-none [&_svg:not([class*='size-'])]:size-5",
TILE[variant],
)}
style={flowToneStyle(item.tone ?? tone)}
>
{item.content}
</div>
</motion.div>
) : null}
</AnimatePresence>
</div>
)
})}
</div>
{groups ? (
<ShuffleGridPills
label={groupsLabel}
options={groups}
value={group}
onValueChange={(id) => {
holdUntil.current = Date.now() + groupInterval * 2
setGroup(id)
}}
/>
) : null}
</div>
)
}
type ShuffleGridAvatarProps = React.ComponentProps<"span"> & {
/** 姓名,取第一个字(拉丁字母转大写)作首字母 */
name: string
}
/** 首字母头像:圆底加一个字,颜色随格子的色调,亮起时更深。适合放进 `content`。 */
function ShuffleGridAvatar({
className,
name,
...props
}: ShuffleGridAvatarProps) {
const initial = Array.from(name.trim())[0]?.toUpperCase() ?? "?"
return (
<span
data-slot="shuffle-grid-avatar"
aria-hidden="true"
className={cn(
"flex size-[58%] items-center justify-center rounded-full bg-muted text-sm font-medium text-muted-foreground ring-1 ring-edge transition-colors duration-500 group-data-lit/tile:bg-(--flow-tone)/15 group-data-lit/tile:text-(--flow-tone) group-data-lit/tile:ring-(--flow-tone)/40",
className,
)}
{...props}
>
{initial}
</span>
)
}
export { ShuffleGrid, ShuffleGridAvatar, ShuffleGridTuningContext }
export type {
ShuffleGridAvatarProps,
ShuffleGridGroup,
ShuffleGridItem,
ShuffleGridProps,
ShuffleGridReport,
ShuffleGridTuning,
ShuffleGridVariant,
}
/**
* 动态格的洗牌逻辑:全部是纯函数,随机数来自种子,所以同一个 `seed` 在服务端与浏览器得到同一张初始画面,
* 之后每一步由「种子 + 第几步」决定,行为可复现。
*/
/** 每个格子里放的是 `items` 的下标,`null` 表示空格。 */
type Slots = (number | null)[]
type Random = () => number
function createRandom(seed: number): Random {
let state = (seed * 2654435761 + 1013904223) >>> 0
return () => {
state = (state + 0x6d2b79f5) >>> 0
let t = state
t = Math.imul(t ^ (t >>> 15), t | 1)
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
function pick<T>(list: T[], random: Random): T {
return list[Math.floor(random() * list.length)]
}
function shuffled<T>(list: T[], random: Random): T[] {
const result = [...list]
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(random() * (i + 1))
;[result[i], result[j]] = [result[j], result[i]]
}
return result
}
const indicesWhere = (slots: Slots, empty: boolean) =>
slots.flatMap((value, index) => ((value === null) === empty ? [index] : []))
/** 随机挑 `activeCount` 个条目,放进随机的格子。 */
function initialSlots(
cells: number,
poolSize: number,
activeCount: number,
random: Random,
): Slots {
const slots: Slots = Array(cells).fill(null)
const spots = shuffled(
Array.from({ length: cells }, (_, i) => i),
random,
)
const pool = shuffled(
Array.from({ length: poolSize }, (_, i) => i),
random,
)
for (let i = 0; i < Math.min(activeCount, cells, poolSize); i++)
slots[spots[i]] = pool[i]
return slots
}
/**
* 走一步:数量不足就补一个、多了就拿掉一个;数量正好时随机在「换格」「换人」「互换」里选一种。
* 每一步只动一两个格子,画面始终是渐变的,不会整盘重排。
*/
function stepSlots(
slots: Slots,
poolSize: number,
activeCount: number,
random: Random,
): Slots {
const next = [...slots]
const filled = indicesWhere(next, false)
const empty = indicesWhere(next, true)
const used = new Set(next.filter((v): v is number => v !== null))
const unused = Array.from({ length: poolSize }, (_, i) => i).filter(
(i) => !used.has(i),
)
if (filled.length > activeCount) {
next[pick(filled, random)] = null
} else if (filled.length < activeCount && empty.length && unused.length) {
next[pick(empty, random)] = pick(unused, random)
} else {
const roll = random()
if (roll < 0.4 && empty.length && filled.length) {
const from = pick(filled, random)
next[pick(empty, random)] = next[from]
next[from] = null
} else if (roll < 0.8 && empty.length && unused.length && filled.length) {
next[pick(filled, random)] = null
next[pick(empty, random)] = pick(unused, random)
} else if (filled.length > 1) {
const a = pick(filled, random)
const b = pick(
filled.filter((i) => i !== a),
random,
)
;[next[a], next[b]] = [next[b], next[a]]
}
}
return next
}
/**
* 切换到某个分类:不在目标里的条目消失,已经在场的有一半挪到别的空格,缺的补进随机空格。
* 已经在场的条目保持同一个下标,这样切换时能看到它们「换位」而不是重新出现。
*/
function arrangeSlots(slots: Slots, target: number[], random: Random): Slots {
const wanted = new Set(target)
const next: Slots = slots.map((v) => (v !== null && wanted.has(v) ? v : null))
for (const from of shuffled(indicesWhere(next, false), random)) {
const empty = indicesWhere(next, true)
if (empty.length && random() < 0.5) {
next[pick(empty, random)] = next[from]
next[from] = null
}
}
const present = new Set(next.filter((v): v is number => v !== null))
for (const id of target) {
if (present.has(id)) continue
const empty = indicesWhere(next, true)
if (!empty.length) break
next[pick(empty, random)] = id
}
return next
}
/** 从在场的条目里挑 `count` 个点亮,返回它们的下标。 */
function pickLit(slots: Slots, count: number, random: Random): number[] {
const present = slots.filter((v): v is number => v !== null)
return shuffled(present, random).slice(0, count)
}
export { arrangeSlots, createRandom, initialSlots, pickLit, stepSlots }
export type { Random, Slots }
"use client"
import * as React from "react"
import { useReducedMotion } from "motion/react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
type ShuffleGridPillsProps = Omit<React.ComponentProps<"div">, "onChange"> & {
/** 分类标签,顺序即显示顺序 */
options: { id: string; label: string }[]
/** 当前选中的分类 id */
value: string
onValueChange: (id: string) => void
/** 读屏名称 */
label?: string
}
/** 底部分类胶囊:一行可横向滚动的按钮,选中项自动滚到中间,两端渐隐。 */
function ShuffleGridPills({
className,
options,
value,
onValueChange,
label = "分类",
...props
}: ShuffleGridPillsProps) {
const ref = React.useRef<HTMLDivElement>(null)
const reduced = useReducedMotion()
// 只在选中项变化时把它滚到中间;用 scrollTo 只动这一行,不会带着整个页面滚
React.useEffect(() => {
const row = ref.current
const active = row?.querySelector<HTMLElement>("[aria-pressed=true]")
if (!row || !active || row.scrollWidth <= row.clientWidth) return
row.scrollTo({
left: active.offsetLeft - (row.clientWidth - active.offsetWidth) / 2,
behavior: reduced ? "auto" : "smooth",
})
}, [value, reduced])
return (
<div
ref={ref}
role="group"
aria-label={label}
data-slot="shuffle-grid-pills"
className={cn(
// 首尾子项的 auto 外边距:放得下时整行居中,放不下时从左滚动,不会把左端挤出可视范围
"flex [scrollbar-width:none] gap-2 overflow-x-auto [&::-webkit-scrollbar]:hidden [&>:first-child]:ml-auto [&>:last-child]:mr-auto",
className,
)}
{...props}
>
{options.map((option) => (
<Button
key={option.id}
type="button"
size="sm"
variant="ghost"
aria-pressed={option.id === value}
onClick={() => onValueChange(option.id)}
className="shrink-0 rounded-full text-muted-foreground aria-pressed:bg-primary/10 aria-pressed:text-primary"
>
{option.label}
</Button>
))}
</div>
)
}
export { ShuffleGridPills }
export type { ShuffleGridPillsProps }
"use client"
import * as React from "react"
type UseControllableStateParams<T> = {
/** 受控值;传入后组件不再维护内部状态 */
prop?: T
/** 非受控时的初始值 */
defaultProp: T
/** 值变化时回调(受控与非受控都会触发) */
onChange?: (value: T) => void
}
/**
* 同时支持受控与非受控的状态 hook。
* 约定见 component-conventions 技能第 4 节:open/defaultOpen/onOpenChange 等成对 props 都用它实现。
*/
function useControllableState<T>({
prop,
defaultProp,
onChange,
}: UseControllableStateParams<T>) {
const [uncontrolled, setUncontrolled] = React.useState(defaultProp)
const isControlled = prop !== undefined
const value = isControlled ? prop : uncontrolled
// 用 ref 保存最新的回调和值,保证 setValue 引用稳定
const onChangeRef = React.useRef(onChange)
const valueRef = React.useRef(value)
React.useEffect(() => {
onChangeRef.current = onChange
valueRef.current = value
})
const setValue = React.useCallback(
(next: React.SetStateAction<T>) => {
const resolved =
typeof next === "function"
? (next as (prev: T) => T)(valueRef.current)
: next
if (Object.is(resolved, valueRef.current)) return
valueRef.current = resolved
if (!isControlled) setUncontrolled(resolved)
onChangeRef.current?.(resolved)
},
[isControlled],
)
return [value, setValue] as const
}
export { useControllableState }