import {
Orbit,
OrbitCenter,
OrbitItem,
OrbitRing,
} from "@/components/ui/orbit/orbit"
import {
Braces,
Command,
Database,
Globe,
Layers,
Sparkles,
Terminal,
} from "lucide-react"
// 节点用同色淡底与描边;胶囊节点只是把宽度放开
const NODE =
"border-(--flow-tone)/35 bg-[color-mix(in_oklab,var(--flow-tone)_12%,var(--background))] shadow-sm"
const PILL = `${NODE} h-8 w-auto px-2.5 text-[11px]`
export default function OrbitDemo() {
return (
<Orbit
label="Next.js、Tailwind、TypeScript 等工具围绕站点标志运行的多层轨道"
size={360}
className="h-[22.5rem]"
>
{/* 最外层只露出上半弧,边缘渐隐,制造纵深 */}
<OrbitRing
radius={148}
arc="half"
duration={70}
startAngle={-140}
dashed
tone="violet"
>
<OrbitItem tone="violet" className={NODE}>
<Sparkles />
</OrbitItem>
<OrbitItem tone="rose" angle={110} className={NODE}>
<Braces />
</OrbitItem>
</OrbitRing>
<OrbitRing radius={116} duration={56} startAngle={30} tone="cyan">
<OrbitItem tone="foreground" className={PILL}>
Next.js
</OrbitItem>
<OrbitItem tone="cyan" angle={125} className={PILL}>
Tailwind
</OrbitItem>
<OrbitItem tone="blue" angle={245} className={PILL}>
TypeScript
</OrbitItem>
</OrbitRing>
<OrbitRing
radius={84}
duration={40}
reverse
dashed
startAngle={-120}
tone="amber"
>
<OrbitItem tone="green" className={NODE}>
<Database />
</OrbitItem>
<OrbitItem tone="amber" angle={120} className={NODE}>
<Terminal />
</OrbitItem>
<OrbitItem tone="cyan" angle={240} className={NODE}>
<Globe />
</OrbitItem>
</OrbitRing>
<OrbitRing radius={50} duration={28} startAngle={20} tone="blue">
<OrbitItem tone="blue" className={NODE}>
<Command />
</OrbitItem>
<OrbitItem tone="violet" angle={180} className={NODE}>
<Layers />
</OrbitItem>
</OrbitRing>
<OrbitCenter className="size-14 rounded-xl bg-primary text-primary-foreground shadow-md ring-8 ring-primary/10">
<span className="font-mono text-xl font-semibold">T.</span>
</OrbitCenter>
</Orbit>
)
}
安装
pnpm dlx shadcn@latest add https://yiui.dev/r/orbit.json用法
用 OrbitRing 定义轨道,用 OrbitItem 放入任意非交互节点。同一圈的节点通过 angle 分开,位置变化时内容始终保持正向。
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)] 把标签抬到地平线上方。
import {
Orbit,
OrbitCenter,
OrbitItem,
OrbitRing,
} from "@/components/ui/orbit/orbit"
import {
Blocks,
Braces,
Component,
Layers,
Palette,
Sparkles,
Terminal,
} from "lucide-react"
export default function OrbitHalfDemo() {
return (
<Orbit
label="上半圆轨道,节点在地平线处淡出"
anchor="bottom"
className="h-44"
>
<OrbitRing radius={80} arc="half" duration={20} startAngle={-120}>
<OrbitItem>
<Braces />
</OrbitItem>
<OrbitItem angle={120}>
<Terminal />
</OrbitItem>
<OrbitItem angle={240}>
<Layers />
</OrbitItem>
</OrbitRing>
<OrbitRing
radius={140}
arc="half"
dashed
reverse
duration={32}
startAngle={-60}
>
<OrbitItem>
<Component />
</OrbitItem>
<OrbitItem angle={90}>
<Palette />
</OrbitItem>
<OrbitItem angle={180}>
<Sparkles />
</OrbitItem>
<OrbitItem angle={270}>
<Blocks />
</OrbitItem>
</OrbitRing>
<OrbitCenter className="-translate-y-[calc(100%+0.5rem)] rounded-lg border border-edge bg-background px-3 py-1.5 font-mono text-xs">
registry
</OrbitCenter>
</Orbit>
)
}
色调
OrbitRing 的 tone 给轨道线上色,OrbitItem 的 tone 给节点上色,两者互不影响;不传 tone 的轨道沿用主题线条色。底纹换成方格,中心用代码图标连接不同工具。
import {
Orbit,
OrbitCenter,
OrbitItem,
OrbitRing,
} from "@/components/ui/orbit/orbit"
import {
Braces,
Code,
Database,
Globe,
Layers,
Terminal,
Zap,
} from "lucide-react"
export default function OrbitIconsDemo() {
return (
<Orbit label="代码节点连接数据库、终端与网络" background="grid" className="h-80">
<OrbitRing radius={74} duration={26} tone="blue">
<OrbitItem tone="blue">
<Database />
</OrbitItem>
<OrbitItem tone="green" angle={120}>
<Terminal />
</OrbitItem>
<OrbitItem tone="amber" angle={240}>
<Zap />
</OrbitItem>
</OrbitRing>
<OrbitRing
radius={126}
duration={38}
reverse
dashed
startAngle={-30}
tone="violet"
>
<OrbitItem tone="violet">
<Layers />
</OrbitItem>
<OrbitItem tone="cyan" angle={120}>
<Globe />
</OrbitItem>
<OrbitItem tone="rose" angle={240}>
<Braces />
</OrbitItem>
</OrbitRing>
<OrbitCenter className="size-16 rounded-xl border border-edge bg-background text-primary">
<Code className="size-7" />
</OrbitCenter>
</Orbit>
)
}
技术栈
节点可以放文字,追加 w-auto px-3 即可呈现胶囊式标签。宽标签需要给轨道两侧留出空间:示例的轨道半径取 96,窄屏下标签也不会被外框裁切。
import {
Orbit,
OrbitCenter,
OrbitItem,
OrbitRing,
} from "@/components/ui/orbit/orbit"
// 文字节点:`w-auto px-3` 把方块撑成胶囊
const PILL = "w-auto px-3 text-xs"
export default function OrbitStackDemo() {
return (
<Orbit
label="技术栈:React、Next.js、TypeScript、Tailwind"
background="none"
className="h-80"
>
<OrbitRing radius={96} duration={36} dashed>
<OrbitItem className={PILL}>React</OrbitItem>
<OrbitItem angle={90} className={PILL}>
Next.js
</OrbitItem>
<OrbitItem angle={180} className={PILL}>
TypeScript
</OrbitItem>
<OrbitItem angle={270} className={PILL}>
Tailwind
</OrbitItem>
</OrbitRing>
<OrbitCenter className="rounded-lg border border-edge bg-background px-3 py-1.5 text-xs text-muted-foreground">
工具箱
</OrbitCenter>
</Orbit>
)
}
API
Orbit
size 默认 320,表示正方形设计画布边长;容器更窄时画布缩小,轨道按比例缩放,节点文字保留原字号。外框默认高 288px,可以用 className 调整(画布 320 时建议 h-80,避免最外圈节点被裁切)。支持 dots / grid / lines / hatch / none 底纹。anchor 默认 center,bottom 让轨道中心落在外框底边。
设计画布边长;半径以此为坐标,窄容器内自动缩小。
类型number | undefinedboolean | undefinedFlowBackgroundName | undefined画布位置;bottom 把轨道中心放到外框底边,上半部分留在可视区内。@default "center"
OrbitAnchor | undefined传入说明后作为图片朗读;默认作为装饰隐藏。内部不应放交互元素。
类型string | undefinedOrbitRing
radius 默认 100,单位是设计像素;duration 默认 24 秒。startAngle 默认 -90,0 度在右侧,正值顺时针。reverse 改变运动方向。tone 只作用于轨道线,支持内置色调名或任意 CSS 颜色。父级或当前轨道 paused 时暂停,恢复后从当前角度继续。
number | undefined完整转一圈的秒数。
类型number | undefinedboolean | undefinedboolean | undefined起始角度,0 在右侧,正值顺时针。
类型number | undefinedhalf 只显示上半圆,节点在下半圆时隐藏,边缘渐隐。
类型OrbitArc | undefinedboolean | undefined轨道线的色调:内置名或任意 CSS 颜色;不传沿用主题的线条色。节点的颜色由各自的 tone 决定。
FlowTone | undefinedOrbitItem
angle 是相对轨道起始角的偏移,默认 0。tone 默认 foreground,支持主题色及 blue / green / amber / rose / violet / cyan。必须置于 OrbitRing 内。
同一轨道内节点之间的角度偏移。
类型number | undefinedFlowToneName | undefinedOrbitCenter
将内容放在画布正中央,自行组合标志、图标或文字。
OrbitCenter 没有自定义 Props;该组件继承原生元素的全部属性。
可访问性
- 默认作为装饰隐藏;传入
label后使用role="img"和文字说明,说明应概括整张图。 - 图示内不放按钮或链接,避免隐藏内容仍可获得键盘焦点。
- 尊重系统“减少动态效果”,以指定的起始角展示静帧;离开视口时自动暂停。
- 色调之外请用图标或文字区分节点;轨道线的颜色只是装饰。
注意事项
- 调整面板通过
OrbitTuningContext覆盖子树里的paused、background和每条轨道的速度、方向、dashed、tone,并用onReport回报示例自己的取值。不包这一层时组件行为不变;它面向调试与文档,正常使用不需要。 - 动画由
motion/react的 MotionValue 驱动,不逐帧触发 React 渲染。服务端首帧不依赖随机值或浏览器尺寸,可用于静态导出。 - 底纹、
--edge和多彩变量由flow-diagram的 registry 依赖一并提供,手动安装时也需要这些依赖。 - 所有同圈节点默认在同一位置,请显式设置不同
angle。最大半径加节点半宽应小于可用画布尺寸的一半;超出的部分由外框裁切。
源码
"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 }