跳至正文
组件

Shuffle Grid动态格

方格里的内容随机出现、换位、点亮,支持分类胶囊切换、首字母头像、纯色像素块与离屏暂停。

正在加载…

安装

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

用法

items 是条目池,每个条目有唯一的 id、可选的 content(图标、头像、任意节点)和 tone。rows、cols 决定盘面大小,activeCount 决定同时在场几个。组件每隔 interval 毫秒走一步:补一个、拿掉一个、换个格子或两个互换,每步只动一两个格子,画面始终是渐变的。

components/shuffle-grid-example.tsx
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,只显示首字,不使用真人照片。

正在加载…

像素块

不传 content,条目就是一块纯色方块;variant="pixels" 把格子收紧成像素风,interval 调小让它快速出没。

正在加载…

API

ShuffleGrid

variant 有三种外观:lines 共享 1px 分隔线的整块网格(默认),tiles 独立的圆角格,pixels 紧凑的纯色像素块。litCount 控制同时点亮几个,点亮的条目用自己的 tone,其余是中性色;传 "all" 全部点亮。

分类相关:value / defaultValue / onValueChange 以分类 id 同时支持受控与非受控;groupInterval 设为 0 关闭自动轮播,用户手动选过后会多等一轮再继续。

ShuffleGridItem 的字段:id、content、tone;ShuffleGridGroup 的字段:id、label、items(条目 id 的数组,数量不能超过格子数)。

ShuffleGridAvatar

首字母头像:圆底加一个字,亮起时换成条目的色调。放进 content,大小随格子自适应,也可以用 className 指定。

可访问性

  • 盘面默认作为装饰隐藏;传 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 条目,安装时会一并提供。

源码

components/ui/shuffle-grid/shuffle-grid.tsx
"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,
}
components/ui/shuffle-grid/engine.ts
/**
 * 动态格的洗牌逻辑:全部是纯函数,随机数来自种子,所以同一个 `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 }
components/ui/shuffle-grid/pills.tsx
"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 }
hooks/use-controllable-state.ts
"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 }