跳至正文
组件

Orbit轨道环绕

可组合的同心轨道与正向节点,支持半弧、反向运行与离屏暂停。

正在加载…

安装

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

用法

用 OrbitRing 定义轨道,用 OrbitItem 放入任意非交互节点。同一圈的节点通过 angle 分开,位置变化时内容始终保持正向。

components/orbit-example.tsx
import {
  Orbit,
  OrbitRing,
  OrbitItem,
  OrbitCenter,
} from "@/components/ui/orbit/orbit"

export function OrbitExample() {
  return (
    <Orbit label="工具围绕核心环绕">
      <OrbitRing radius={100} duration={24}>
        <OrbitItem>01</OrbitItem>
        <OrbitItem angle={180}>02</OrbitItem>
      </OrbitRing>
      <OrbitCenter>核心</OrbitCenter>
    </Orbit>
  )
}

示例

每个示例的标题栏右侧都有「调整」开关:打开后可以实时调整速度、轨道线样式、色调与底纹,并开关整体反向与暂停;这些都是对示例里所有轨道的整体覆盖,未改动的项显示示例自己的取值。调整时外框与画布大小不变。

半弧

arc="half" 保留上半圆,并在地平线处渐隐。节点继续沿整圆运动,在下半圆时隐藏。配合 anchor="bottom" 把轨道中心放到外框底边,高度只留给上半部分,适合放在区块底部。OrbitCenter 默认在轨道中心,示例用 -translate-y-[calc(100%+0.5rem)] 把标签抬到地平线上方。

正在加载…

色调

OrbitRing 的 tone 给轨道线上色,OrbitItem 的 tone 给节点上色,两者互不影响;不传 tone 的轨道沿用主题线条色。底纹换成方格,中心用代码图标连接不同工具。

正在加载…

技术栈

节点可以放文字,追加 w-auto px-3 即可呈现胶囊式标签。宽标签需要给轨道两侧留出空间:示例的轨道半径取 96,窄屏下标签也不会被外框裁切。

正在加载…

API

Orbit

size 默认 320,表示正方形设计画布边长;容器更窄时画布缩小,轨道按比例缩放,节点文字保留原字号。外框默认高 288px,可以用 className 调整(画布 320 时建议 h-80,避免最外圈节点被裁切)。支持 dots / grid / lines / hatch / none 底纹。anchor 默认 center,bottom 让轨道中心落在外框底边。

OrbitRing

radius 默认 100,单位是设计像素;duration 默认 24 秒。startAngle 默认 -90,0 度在右侧,正值顺时针。reverse 改变运动方向。tone 只作用于轨道线,支持内置色调名或任意 CSS 颜色。父级或当前轨道 paused 时暂停,恢复后从当前角度继续。

OrbitItem

angle 是相对轨道起始角的偏移,默认 0。tone 默认 foreground,支持主题色及 blue / green / amber / rose / violet / cyan。必须置于 OrbitRing 内。

OrbitCenter

将内容放在画布正中央,自行组合标志、图标或文字。

OrbitCenter 没有自定义 Props;该组件继承原生元素的全部属性。

可访问性

  • 默认作为装饰隐藏;传入 label 后使用 role="img" 和文字说明,说明应概括整张图。
  • 图示内不放按钮或链接,避免隐藏内容仍可获得键盘焦点。
  • 尊重系统“减少动态效果”,以指定的起始角展示静帧;离开视口时自动暂停。
  • 色调之外请用图标或文字区分节点;轨道线的颜色只是装饰。

注意事项

  • 调整面板通过 OrbitTuningContext 覆盖子树里的 paused、background 和每条轨道的速度、方向、dashed、tone,并用 onReport 回报示例自己的取值。不包这一层时组件行为不变;它面向调试与文档,正常使用不需要。
  • 动画由 motion/react 的 MotionValue 驱动,不逐帧触发 React 渲染。服务端首帧不依赖随机值或浏览器尺寸,可用于静态导出。
  • 底纹、--edge 和多彩变量由 flow-diagram 的 registry 依赖一并提供,手动安装时也需要这些依赖。
  • 所有同圈节点默认在同一位置,请显式设置不同 angle。最大半径加节点半宽应小于可用画布尺寸的一半;超出的部分由外框裁切。

源码

components/ui/orbit/orbit.tsx
"use client"

import * as React from "react"
import {
  motion,
  useInView,
  useMotionValue,
  useTransform,
  type MotionValue,
} from "motion/react"
import { cn } from "@/lib/utils"
import {
  backgroundClasses,
  FADE_MASK,
  type FlowBackgroundName,
} from "@/components/ui/flow-diagram/background"
import {
  flowToneStyle,
  type FlowTone,
  type FlowToneName,
} from "@/components/ui/flow-diagram/tone"

// 订阅系统偏好的实时变更,服务端始终使用静帧。
function subscribeReducedMotion(callback: () => void) {
  const media = window.matchMedia("(prefers-reduced-motion: reduce)")
  media.addEventListener("change", callback)
  return () => media.removeEventListener("change", callback)
}
function reducedMotionSnapshot() {
  return window.matchMedia("(prefers-reduced-motion: reduce)").matches
}
function serverReducedMotionSnapshot() {
  return true
}
function useOrbitReducedMotion() {
  return React.useSyncExternalStore(
    subscribeReducedMotion,
    reducedMotionSnapshot,
    serverReducedMotionSnapshot,
  )
}

type OrbitArc = "full" | "half"
/** 画布在外框里的位置:`bottom` 让轨道中心落在外框底边,配合 `arc="half"` 得到贴地的半圆。 */
type OrbitAnchor = "center" | "bottom"

/**
 * 调整用的覆盖项:`Orbit` 子树里所有轨道的对应属性被这里的值替换。
 * 用于调试面板一类的场景;不包 `OrbitTuningContext.Provider` 时组件行为不变。
 */
type OrbitTuning = {
  /** 速度倍率,轨道实际用时为 `duration / speed`。 */
  speed?: number
  /** 为 true 时所有轨道的方向取反。 */
  flip?: boolean
  /** 覆盖根的 `paused`。 */
  paused?: boolean
  /** 覆盖每条轨道的 `dashed`。 */
  dashed?: boolean
  /** 覆盖每条轨道的 `tone`;`false` 表示不上色,沿用主题线条色。 */
  tone?: FlowTone | false
  /** 覆盖根的 `background`。 */
  background?: FlowBackgroundName
  /** 回报示例自己的实际取值(未被覆盖前),面板据此显示当前值。 */
  onReport?: (values: OrbitReport) => void
}

type OrbitReport = {
  paused: boolean
  background: FlowBackgroundName
}

const OrbitTuningContext = React.createContext<OrbitTuning>({})

const OrbitContext = React.createContext<{
  active: boolean
  size: number
  speed: number
  flip: boolean
  dashed?: boolean
  tone?: FlowTone | false
}>({ active: false, size: 320, speed: 1, flip: false })
const OrbitRingContext = React.createContext<{
  angle: MotionValue<number>
} | null>(null)

function Orbit({
  size = 320,
  paused: pausedProp = false,
  background: backgroundProp = "dots",
  anchor = "center",
  label,
  className,
  children,
  ...props
}: React.ComponentProps<"div"> & {
  /** 设计画布边长;半径以此为坐标,窄容器内自动缩小。 */
  size?: number
  paused?: boolean
  background?: FlowBackgroundName
  /** 画布位置;`bottom` 把轨道中心放到外框底边,上半部分留在可视区内。@default "center" */
  anchor?: OrbitAnchor
  /** 传入说明后作为图片朗读;默认作为装饰隐藏。内部不应放交互元素。 */
  label?: string
}) {
  const ref = React.useRef<HTMLDivElement>(null)
  const inView = useInView(ref, { amount: 0.2 })
  const reduced = useOrbitReducedMotion()
  const tuning = React.useContext(OrbitTuningContext)
  const { onReport } = tuning
  const paused = tuning.paused ?? pausedProp
  const background = tuning.background ?? backgroundProp
  React.useEffect(() => {
    onReport?.({ paused: pausedProp, background: backgroundProp })
  }, [onReport, pausedProp, backgroundProp])
  return (
    <div
      {...props}
      ref={ref}
      data-slot="orbit"
      data-anchor={anchor}
      role={label ? "img" : undefined}
      aria-label={label}
      aria-hidden={label ? undefined : true}
      className={cn(
        "relative flex h-72 w-full items-center justify-center overflow-hidden",
        anchor === "bottom" && "items-end",
        className,
      )}
    >
      <div
        data-slot="orbit-background"
        className={cn(
          "pointer-events-none absolute inset-0",
          backgroundClasses[background],
          FADE_MASK,
        )}
      />
      <div
        data-slot="orbit-canvas"
        className={cn(
          "relative aspect-square shrink-0",
          anchor === "bottom" && "translate-y-1/2",
        )}
        style={{ width: `min(100%, ${Math.max(1, size)}px)` }}
      >
        <OrbitContext.Provider
          value={{
            active: inView && !paused && !reduced,
            size: Math.max(1, size),
            speed: Math.max(0.01, tuning.speed ?? 1),
            flip: tuning.flip ?? false,
            dashed: tuning.dashed,
            tone: tuning.tone,
          }}
        >
          {children}
        </OrbitContext.Provider>
      </div>
    </div>
  )
}

function OrbitRing({
  radius = 100,
  duration = 24,
  reverse: reverseProp = false,
  paused = false,
  startAngle = -90,
  arc = "full",
  dashed: dashedProp = false,
  tone: toneProp,
  className,
  style,
  children,
  ...props
}: React.ComponentProps<"div"> & {
  radius?: number
  /** 完整转一圈的秒数。 */
  duration?: number
  reverse?: boolean
  paused?: boolean
  /** 起始角度,0 在右侧,正值顺时针。 */
  startAngle?: number
  /** half 只显示上半圆,节点在下半圆时隐藏,边缘渐隐。 */
  arc?: OrbitArc
  dashed?: boolean
  /** 轨道线的色调:内置名或任意 CSS 颜色;不传沿用主题的线条色。节点的颜色由各自的 `tone` 决定。 */
  tone?: FlowTone
}) {
  const { active, size, speed, flip, ...overrides } =
    React.useContext(OrbitContext)
  const reverse = flip ? !reverseProp : reverseProp
  const dashed = overrides.dashed ?? dashedProp
  const tone =
    overrides.tone === false ? undefined : (overrides.tone ?? toneProp)
  const angle = useMotionValue(startAngle)
  const reduced = useOrbitReducedMotion()
  React.useEffect(() => {
    angle.set(startAngle)
  }, [angle, startAngle, reduced])
  React.useEffect(() => {
    if (!active || paused) return
    let last = performance.now()
    let frame = requestAnimationFrame(function tick(now) {
      angle.set(
        angle.get() +
          (((Math.min(now - last, 100) / 1000) * 360) /
            Math.max(0.1, duration / speed)) *
            (reverse ? -1 : 1),
      )
      last = now
      frame = requestAnimationFrame(tick)
    })
    return () => cancelAnimationFrame(frame)
  }, [active, paused, duration, speed, reverse, angle])
  const diameter = ((Math.max(0, radius) * 2) / size) * 100
  return (
    <div
      {...props}
      data-slot="orbit-ring"
      data-arc={arc}
      className={cn(
        "pointer-events-none absolute inset-0",
        arc === "half" &&
          "[mask-image:linear-gradient(to_bottom,black_40%,transparent_52%)]",
        className,
      )}
      style={style}
    >
      <div
        data-slot="orbit-path"
        className={cn(
          "absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full border",
          tone ? "border-(--flow-tone)/40" : "border-edge",
          dashed && "border-dashed",
        )}
        style={{
          width: `${diameter}%`,
          height: `${diameter}%`,
          ...flowToneStyle(tone),
        }}
      >
        <OrbitRingContext.Provider value={{ angle }}>
          {children}
        </OrbitRingContext.Provider>
      </div>
    </div>
  )
}

function OrbitItem({
  angle: offset = 0,
  tone = "foreground",
  className,
  style,
  ...props
}: React.ComponentProps<"div"> & {
  /** 同一轨道内节点之间的角度偏移。 */
  angle?: number
  tone?: FlowToneName
}) {
  const ring = React.useContext(OrbitRingContext)
  if (!ring) throw new Error("OrbitItem 必须放在 OrbitRing 内")
  const x = useTransform(
    ring.angle,
    (value) =>
      `${(Math.cos(((value + offset) * Math.PI) / 180) * 50).toFixed(3)}%`,
  )
  const y = useTransform(
    ring.angle,
    (value) =>
      `${(Math.sin(((value + offset) * Math.PI) / 180) * 50).toFixed(3)}%`,
  )
  return (
    <motion.div
      data-slot="orbit-item-position"
      className="absolute inset-0 flex items-center justify-center"
      style={{ x, y }}
    >
      <div
        {...props}
        data-slot="orbit-item"
        className={cn(
          "flex size-10 shrink-0 items-center justify-center rounded-lg border border-edge bg-background font-mono text-xs whitespace-nowrap text-(--flow-tone) [&_svg]:size-4",
          className,
        )}
        style={{ ...flowToneStyle(tone), ...style }}
      >
        {props.children}
      </div>
    </motion.div>
  )
}

function OrbitCenter({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      {...props}
      data-slot="orbit-center"
      className={cn(
        "absolute top-1/2 left-1/2 z-10 flex -translate-x-1/2 -translate-y-1/2 items-center justify-center",
        className,
      )}
    />
  )
}

export { Orbit, OrbitRing, OrbitItem, OrbitCenter, OrbitTuningContext }
export type { OrbitAnchor, OrbitArc, OrbitReport, OrbitTuning }