语音律动球
New沉浸式语音模式光球:单一连续渐变球体,呼吸、色相漂移、回声环与思考高光随待机/聆听/思考/说话四态切换。
VOICE
Listening…
"Summarize today's standup — pull out the blockers and who owns each one."
TSXcomponents/previews/blocks/voice-orb.preview.tsx
"use client";
import { MicOff, X } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useState } from "react";
import { VoiceOrb, type VoiceOrbState } from "@/components/motion/voice-orb";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
const STATES: Array<{ id: VoiceOrbState; label: string }> = [
{ id: "idle", label: "Idle" },
{ id: "listening", label: "Listening" },
{ id: "thinking", label: "Thinking" },
{ id: "speaking", label: "Speaking" },
];
const STATUS_LABEL: Record<VoiceOrbState, string> = {
idle: "Idle",
listening: "Listening…",
thinking: "Thinking…",
speaking: "Speaking…",
};
const TRANSCRIPT =
'"Summarize today\'s standup — pull out the blockers and who owns each one."';
/**
* Voice mode is inherently a full-dark surface (phone assistants, live
* voice calls) regardless of the site's light/dark theme, so this card
* hardcodes its own dark palette instead of branching on `dark:` — the
* only preview in the library that intentionally does this.
*/
export function VoiceOrbPreview() {
const [state, setState] = useState<VoiceOrbState>("listening");
return (
<div
className="relative flex h-[480px] w-full flex-col items-center overflow-hidden rounded-xl border border-white/10 px-6 py-6"
style={{
background:
"radial-gradient(120% 90% at 50% -10%, #16204a 0%, #0a0e1f 55%, #030308 100%)",
}}
>
<div className="flex w-full items-center justify-between">
<span className="text-[11px] tracking-[0.2em] text-white/40">
VOICE
</span>
<div className="flex gap-1 rounded-full border border-white/10 bg-white/5 p-1 backdrop-blur">
{STATES.map((s) => (
<button
key={s.id}
type="button"
aria-pressed={state === s.id}
onClick={() => setState(s.id)}
className={cn(
"h-6 rounded-full px-2.5 text-xs transition-colors",
state === s.id
? "bg-white text-[#0a0e1f]"
: "text-white/50 hover:text-white/80",
)}
>
{s.label}
</button>
))}
</div>
</div>
<div className="flex flex-1 flex-col items-center justify-center gap-5">
<VoiceOrb state={state} size={180} />
<div className="flex flex-col items-center gap-2 px-6 text-center">
<AnimatePresence mode="wait" initial={false}>
<motion.span
key={state}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.15, ease: EASE_OUT }}
className="text-sm text-white/90"
>
{STATUS_LABEL[state]}
</motion.span>
</AnimatePresence>
<AnimatePresence initial={false}>
{state === "listening" ? (
<motion.p
key="transcript"
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.15, ease: EASE_OUT }}
className="min-h-[32px] max-w-xs text-[13px] italic leading-relaxed text-white/55"
>
{TRANSCRIPT}
</motion.p>
) : null}
</AnimatePresence>
</div>
</div>
<div className="flex items-center gap-7 pb-1">
<button
type="button"
aria-label="Mute microphone"
className="flex h-11 w-11 items-center justify-center rounded-full bg-white/10 text-white backdrop-blur transition-colors hover:bg-white/15"
>
<MicOff className="h-[18px] w-[18px]" />
</button>
<button
type="button"
aria-label="Exit voice mode"
className="flex h-11 w-11 items-center justify-center rounded-full bg-white/15 text-white backdrop-blur transition-colors hover:bg-white/20"
>
<X className="h-[18px] w-[18px]" />
</button>
</div>
</div>
);
}
TSXcomponents/motion/voice-orb/index.tsx
"use client";
// ui-lab-ten.vercel.app/components/blocks/voice-orb
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { EASE_OUT, SPRING_PANEL } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type VoiceOrbState = "idle" | "listening" | "thinking" | "speaking";
export interface VoiceOrbProps {
/** Conversational state — drives breathing rhythm, hue drift, echo rings and the thinking sheen. */
state?: VoiceOrbState;
/** Orb diameter in pixels. */
size?: number;
className?: string;
}
// Brand blue (#339CFF — the library's accent, used elsewhere for focus rings)
// carried as an rgb triplet so the glow shadow can share it at low alpha.
const GLOW_RGB = "51, 156, 255";
const ORB_BACKGROUND =
"radial-gradient(circle at 30% 26%, #d9ecff 0%, #339CFF 38%, #1c5aa8 68%, #0a2540 100%)";
const ORB_SHADOW = [
`0 0 64px rgba(${GLOW_RGB}, 0.45)`,
`0 0 130px rgba(${GLOW_RGB}, 0.22)`,
"inset 0 -20px 42px rgba(4, 12, 26, 0.4)",
"inset 0 16px 28px rgba(255, 255, 255, 0.2)",
].join(", ");
/**
* Per-state breathing rhythm — the ball's own continuous `animate` target.
* These durations/eases are bespoke to the breathing/echo feel rather than UI
* transitions, so they live as named constants instead of `lib/ease.ts`
* tokens (which are tuned for <300ms UI moves and panel entrances): idle is
* a slow, low-lit drift; listening/speaking sync the scale with a hue or
* brightness pulse; thinking calms the breathing so the rotating sheen below
* carries the motion instead.
*/
const BREATH: Record<
VoiceOrbState,
{ scale: number[]; filter: string | string[]; duration: number }
> = {
idle: {
scale: [1, 1.03, 1],
filter: "brightness(0.85)",
duration: 5,
},
listening: {
scale: [1, 1.06, 1],
filter: [
"hue-rotate(0deg) brightness(1)",
"hue-rotate(15deg) brightness(1.05)",
"hue-rotate(0deg) brightness(1)",
],
duration: 2.4,
},
thinking: {
scale: [1, 1.02, 1],
filter: ["brightness(0.95)", "brightness(1.05)", "brightness(0.95)"],
duration: 3.2,
},
speaking: {
scale: [1, 1.09, 1.02, 1.12, 1],
filter: ["brightness(1)", "brightness(1.15)", "brightness(1)"],
duration: 1.6,
},
};
/** Expanding echo-ring, cloned twice with a stagger — a decelerating ripple radiating off the ball's edge. */
function EchoRing({ duration, delay }: { duration: number; delay: number }) {
return (
<motion.span
aria-hidden
className="absolute inset-0 rounded-full border border-[#339CFF]/40"
initial={{ scale: 1, opacity: 0.5 }}
animate={{ scale: 1.6, opacity: 0 }}
transition={{ duration, delay, repeat: Infinity, ease: "easeOut" }}
/>
);
}
/**
* Central voice-mode orb — a single continuous ball that never unmounts
* across `idle` / `listening` / `thinking` / `speaking`; only the breathing
* `animate` target changes, so the motion carries through state switches
* instead of resetting. Echo rings (listening/speaking) and the thinking
* sheen are the only pieces that mount/unmount — they spring in with
* `SPRING_PANEL` and fade out with `EASE_OUT`, quicker out than in, matching
* the library's exit convention. Fully static under `useReducedMotion()`: no
* breathing, no rings, no rotation — just the plain gradient ball.
*
* Purely decorative (`aria-hidden`); pair it with your own status text for
* the accessible state announcement.
*/
export function VoiceOrb({
state = "idle",
size = 160,
className,
}: VoiceOrbProps) {
const reduce = useReducedMotion() ?? false;
const breath = BREATH[state];
const showRings = state === "listening" || state === "speaking";
const showSheen = state === "thinking";
// Speaking answers back faster than listening picks up — the echo halves in length.
const echoDuration = state === "speaking" ? 1.2 : 2;
return (
<div
aria-hidden
className={cn(
"relative inline-flex items-center justify-center",
className,
)}
style={{ width: size, height: size }}
>
<AnimatePresence>
{!reduce && showRings ? (
<motion.span
key="rings"
className="absolute inset-0"
initial={{ opacity: 0, scale: 0.85 }}
animate={{ opacity: 1, scale: 1 }}
exit={{
opacity: 0,
scale: 0.9,
transition: { duration: 0.15, ease: EASE_OUT },
}}
transition={SPRING_PANEL}
>
<EchoRing duration={echoDuration} delay={0} />
<EchoRing duration={echoDuration} delay={echoDuration / 2} />
</motion.span>
) : null}
</AnimatePresence>
<motion.div
className="relative rounded-full"
style={{
width: "100%",
height: "100%",
background: ORB_BACKGROUND,
boxShadow: ORB_SHADOW,
}}
animate={
reduce ? undefined : { scale: breath.scale, filter: breath.filter }
}
transition={
reduce
? undefined
: { duration: breath.duration, repeat: Infinity, ease: "easeInOut" }
}
>
<AnimatePresence>
{!reduce && showSheen ? (
<motion.span
key="sheen"
aria-hidden
className="absolute inset-0 overflow-hidden rounded-full mix-blend-overlay"
style={{
background:
"conic-gradient(from 0deg, transparent 0deg, rgba(255,255,255,0.5) 50deg, transparent 130deg, transparent 360deg)",
}}
initial={{ opacity: 0, scale: 0.85, rotate: 0 }}
animate={{ opacity: 1, scale: 1, rotate: 360 }}
exit={{
opacity: 0,
scale: 0.9,
transition: { duration: 0.15, ease: EASE_OUT },
}}
transition={{
opacity: SPRING_PANEL,
scale: SPRING_PANEL,
rotate: { duration: 6, repeat: Infinity, ease: "linear" },
}}
/>
) : null}
</AnimatePresence>
</motion.div>
</div>
);
}
安装
用 shadcn CLI 添加,或手动复制源码。
$ bunx --bun shadcn add @uilab/voice-orb
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
TSXlib/ease.ts
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
TSXlib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
TSXcomponents/motion/voice-orb/index.tsx
"use client";
// ui-lab-ten.vercel.app/components/blocks/voice-orb
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { EASE_OUT, SPRING_PANEL } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type VoiceOrbState = "idle" | "listening" | "thinking" | "speaking";
export interface VoiceOrbProps {
/** Conversational state — drives breathing rhythm, hue drift, echo rings and the thinking sheen. */
state?: VoiceOrbState;
/** Orb diameter in pixels. */
size?: number;
className?: string;
}
// Brand blue (#339CFF — the library's accent, used elsewhere for focus rings)
// carried as an rgb triplet so the glow shadow can share it at low alpha.
const GLOW_RGB = "51, 156, 255";
const ORB_BACKGROUND =
"radial-gradient(circle at 30% 26%, #d9ecff 0%, #339CFF 38%, #1c5aa8 68%, #0a2540 100%)";
const ORB_SHADOW = [
`0 0 64px rgba(${GLOW_RGB}, 0.45)`,
`0 0 130px rgba(${GLOW_RGB}, 0.22)`,
"inset 0 -20px 42px rgba(4, 12, 26, 0.4)",
"inset 0 16px 28px rgba(255, 255, 255, 0.2)",
].join(", ");
/**
* Per-state breathing rhythm — the ball's own continuous `animate` target.
* These durations/eases are bespoke to the breathing/echo feel rather than UI
* transitions, so they live as named constants instead of `lib/ease.ts`
* tokens (which are tuned for <300ms UI moves and panel entrances): idle is
* a slow, low-lit drift; listening/speaking sync the scale with a hue or
* brightness pulse; thinking calms the breathing so the rotating sheen below
* carries the motion instead.
*/
const BREATH: Record<
VoiceOrbState,
{ scale: number[]; filter: string | string[]; duration: number }
> = {
idle: {
scale: [1, 1.03, 1],
filter: "brightness(0.85)",
duration: 5,
},
listening: {
scale: [1, 1.06, 1],
filter: [
"hue-rotate(0deg) brightness(1)",
"hue-rotate(15deg) brightness(1.05)",
"hue-rotate(0deg) brightness(1)",
],
duration: 2.4,
},
thinking: {
scale: [1, 1.02, 1],
filter: ["brightness(0.95)", "brightness(1.05)", "brightness(0.95)"],
duration: 3.2,
},
speaking: {
scale: [1, 1.09, 1.02, 1.12, 1],
filter: ["brightness(1)", "brightness(1.15)", "brightness(1)"],
duration: 1.6,
},
};
/** Expanding echo-ring, cloned twice with a stagger — a decelerating ripple radiating off the ball's edge. */
function EchoRing({ duration, delay }: { duration: number; delay: number }) {
return (
<motion.span
aria-hidden
className="absolute inset-0 rounded-full border border-[#339CFF]/40"
initial={{ scale: 1, opacity: 0.5 }}
animate={{ scale: 1.6, opacity: 0 }}
transition={{ duration, delay, repeat: Infinity, ease: "easeOut" }}
/>
);
}
/**
* Central voice-mode orb — a single continuous ball that never unmounts
* across `idle` / `listening` / `thinking` / `speaking`; only the breathing
* `animate` target changes, so the motion carries through state switches
* instead of resetting. Echo rings (listening/speaking) and the thinking
* sheen are the only pieces that mount/unmount — they spring in with
* `SPRING_PANEL` and fade out with `EASE_OUT`, quicker out than in, matching
* the library's exit convention. Fully static under `useReducedMotion()`: no
* breathing, no rings, no rotation — just the plain gradient ball.
*
* Purely decorative (`aria-hidden`); pair it with your own status text for
* the accessible state announcement.
*/
export function VoiceOrb({
state = "idle",
size = 160,
className,
}: VoiceOrbProps) {
const reduce = useReducedMotion() ?? false;
const breath = BREATH[state];
const showRings = state === "listening" || state === "speaking";
const showSheen = state === "thinking";
// Speaking answers back faster than listening picks up — the echo halves in length.
const echoDuration = state === "speaking" ? 1.2 : 2;
return (
<div
aria-hidden
className={cn(
"relative inline-flex items-center justify-center",
className,
)}
style={{ width: size, height: size }}
>
<AnimatePresence>
{!reduce && showRings ? (
<motion.span
key="rings"
className="absolute inset-0"
initial={{ opacity: 0, scale: 0.85 }}
animate={{ opacity: 1, scale: 1 }}
exit={{
opacity: 0,
scale: 0.9,
transition: { duration: 0.15, ease: EASE_OUT },
}}
transition={SPRING_PANEL}
>
<EchoRing duration={echoDuration} delay={0} />
<EchoRing duration={echoDuration} delay={echoDuration / 2} />
</motion.span>
) : null}
</AnimatePresence>
<motion.div
className="relative rounded-full"
style={{
width: "100%",
height: "100%",
background: ORB_BACKGROUND,
boxShadow: ORB_SHADOW,
}}
animate={
reduce ? undefined : { scale: breath.scale, filter: breath.filter }
}
transition={
reduce
? undefined
: { duration: breath.duration, repeat: Infinity, ease: "easeInOut" }
}
>
<AnimatePresence>
{!reduce && showSheen ? (
<motion.span
key="sheen"
aria-hidden
className="absolute inset-0 overflow-hidden rounded-full mix-blend-overlay"
style={{
background:
"conic-gradient(from 0deg, transparent 0deg, rgba(255,255,255,0.5) 50deg, transparent 130deg, transparent 360deg)",
}}
initial={{ opacity: 0, scale: 0.85, rotate: 0 }}
animate={{ opacity: 1, scale: 1, rotate: 360 }}
exit={{
opacity: 0,
scale: 0.9,
transition: { duration: 0.15, ease: EASE_OUT },
}}
transition={{
opacity: SPRING_PANEL,
scale: SPRING_PANEL,
rotate: { duration: 6, repeat: Infinity, ease: "linear" },
}}
/>
) : null}
</AnimatePresence>
</motion.div>
</div>
);
}
API 参考
state?"idle" | "listening" | "thinking" | "speaking"Conversational state — drives breathing rhythm, hue drift, echo rings and the thinking sheen.
idlesize?numberOrb diameter in pixels.
160className?string—Keep in mind
Some components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.