Add this component

npx zentauri-ui add circular-menu

Accessibility notes

Keyboard accessible by default with semantic markup, visible focus treatment, and tokenized states. Add descriptive labels for icon-only or decorative usage.

Dependency notes

Check Installation for shared peers. This component keeps styling in Tailwind classes and the --zui-* token contract.

Navigation

Circular Menu

The Circular Menu component arranges its items on a circle around a center trigger. Item count, radius, start angle, arc sweep, direction, item size, and continuous rotation are all props, so the same component covers a radial action launcher, a floating action button that fans out, and a decorative spinning orbit.

Circular menu playground

Every part of the ring is a prop: item count, radius, start angle, arc sweep, how it opens, whether it spins, and how labels are revealed. Toggle Show output / Show code and the snippet updates to match the selected variant.

Open the ring, then walk it with the arrow keys — Home and End jump to the first and last item, Escape closes and returns focus to the trigger.


All appearances

Every shipped appearance token at a glance, each shown as an always-open ring. Click any swatch to load it into the playground above.

default
primary
secondary
success
destructive
warning
info
blue
violet
emerald
amber
rose
slate
zinc
gradient-blue
gradient-violet
gradient-rose
glass

Quarter arc

Set sweep below 360 and the items spread across an arc instead of a closed ring, with the first and last item landing exactly on the arc ends. Pair it with startAngle to aim the fan — this one opens up and to the right, the usual floating-action-button shape.


Orbit

trigger="always" keeps the ring open, and spin rotates it continuously. The static entry does this in pure CSS: the ring turns while each item counter-rotates so icons stay upright, hovering pauses it, and prefers-reduced-motion stops it entirely.


Controlled state

Drive the ring from your own state with open / onOpenChange. Here closeOnSelect is off, so picking an item reports through onSelect and leaves the ring open for a second choice.


Last action: none

Circular Menu API

Generated from the package prop types and variant definitions.

CircularMenu

CircularMenuProps

Variants

PropTypeDefault
size
lgmdsm
md

State

PropTypeDefault
defaultOpenboolean | undefinednone
onOpenChange((open: boolean) => void) | undefinednone
openboolean | undefinednone

Behavior

PropTypeDefault
appearance'default' | 'blue' | 'slate' | 'zinc' | 'rose' | 'emerald' | 'gradient-blue' | 'success' | 'warning' | 'info' | 'violet' | 'glass' | 'secondary' | 'destructive' | 'amber' | 'primary' | 'gradient-violet' | 'gradient-rose' | undefinednone
boxSizeSquare box edge in pixels. Defaults to `2 * (radius + itemSize / 2)`.number | undefinednone
closeOnEscapeDefaults to `true`.boolean | undefinednone
closeOnOutsideDefaults to `true`.boolean | undefinednone
closeOnSelectDefaults to `true`.boolean | undefinednone
directionCircularMenuDirection | undefinednone
disabledboolean | undefinednone
itemRotationDefaults to `"upright"`, which counter-rotates icons and labels.CircularMenuItemRotation | undefinednone
itemsShorthand data API. Ignored when the root is composed from children.CircularMenuItemData[] | undefinednone
itemSizeItem diameter in pixels. Defaults to the size variant's item size.number | undefinednone
labelTrigger content, and the accessible name of the menu.ReactNodenone
labelPlacementDefaults to `"tooltip"`, which reveals the label on hover and focus.'none' | 'tooltip' | 'inside' | 'outside' | undefinednone
onSelectRuns after the selected item's own `onSelect`.((item: CircularMenuItemData, index: number) => void) | undefinednone
radiusRing radius in pixels. Defaults to the size variant's radius.number | undefinednone
showSpokesDraw a line from the center to each item.boolean | undefinednone
spinRotate the whole ring continuously.boolean | undefinednone
spinDurationSeconds per revolution. Defaults to `12`.number | undefinednone
spinPauseOnHoverDefaults to `true`.boolean | undefinednone
staggerPer-item open/close delay in milliseconds. Defaults to `40`.number | undefinednone
startAngleAngle of the first item in degrees. `0` = 12 o'clock, positive = clockwise.number | undefinednone
sweepArc covered by the items in degrees. `360` closes the ring.number | undefinednone
triggerDefaults to `"click"`. `"hover"` still opens on click and on focus.CircularMenuTriggerMode | undefinednone

Content

PropTypeDefault
childrenReactNodenone
Inherited HTML props
PropTypeDefault
classNamestring | undefinednone
idstring | undefinednone
onClickMouseEventHandler<HTMLDivElement> | undefinednone
styleCSSProperties | undefinednone
titlestring | undefinednone

CircularMenuItem

CircularMenuItemProps

Variants

PropTypeDefault
appearance
amberbluedefaultdestructiveemeraldglassgradient-bluegradient-rosegradient-violetinfoprimaryrose+6
default

Behavior

PropTypeDefault
hrefRenders the item as an anchor instead of a button.string | undefinednone
onSelect(() => void) | undefinednone
targetHTMLAttributeAnchorTarget | undefinednone
Inherited HTML props
PropTypeDefault
childrenReactNodenone
classNamestring | undefinednone
idstring | undefinednone
onClickMouseEventHandler<HTMLButtonElement> | undefinednone
styleCSSProperties | undefinednone
titlestring | undefinednone

CircularMenuItemIcon

CircularMenuItemIconProps

Inherited HTML props
PropTypeDefault
childrenReactNodenone
classNamestring | undefinednone
idstring | undefinednone
onClickMouseEventHandler<HTMLSpanElement> | undefinednone
styleCSSProperties | undefinednone
titlestring | undefinednone

CircularMenuItemLabel

CircularMenuItemLabelProps

Variants

PropTypeDefault
placement
insidenoneoutsidetooltip
tooltip
Inherited HTML props
PropTypeDefault
childrenReactNodenone
classNamestring | undefinednone
idstring | undefinednone
onClickMouseEventHandler<HTMLSpanElement> | undefinednone
styleCSSProperties | undefinednone
titlestring | undefinednone

CircularMenuList

CircularMenuListProps

Inherited HTML props
PropTypeDefault
childrenReactNodenone
classNamestring | undefinednone
idstring | undefinednone
onClickMouseEventHandler<HTMLDivElement> | undefinednone
styleCSSProperties | undefinednone
titlestring | undefinednone

CircularMenuRoot

CircularMenuRootProps

State

PropTypeDefault
defaultOpenboolean | undefinednone
onOpenChange((open: boolean) => void) | undefinednone
openboolean | undefinednone

Behavior

PropTypeDefault
appearance'default' | 'blue' | 'slate' | 'zinc' | 'rose' | 'emerald' | 'gradient-blue' | 'success' | 'warning' | 'info' | 'violet' | 'glass' | 'secondary' | 'destructive' | 'amber' | 'primary' | 'gradient-violet' | 'gradient-rose' | undefinednone
boxSizeSquare box edge in pixels. Defaults to `2 * (radius + itemSize / 2)`.number | undefinednone
closeOnEscapeDefaults to `true`.boolean | undefinednone
closeOnOutsideDefaults to `true`.boolean | undefinednone
closeOnSelectDefaults to `true`.boolean | undefinednone
directionCircularMenuDirection | undefinednone
disabledboolean | undefinednone
itemRotationDefaults to `"upright"`, which counter-rotates icons and labels.CircularMenuItemRotation | undefinednone
itemsShorthand data API. Ignored when the root is composed from children.CircularMenuItemData[] | undefinednone
itemSizeItem diameter in pixels. Defaults to the size variant's item size.number | undefinednone
labelTrigger content, and the accessible name of the menu.ReactNodenone
labelPlacementDefaults to `"tooltip"`, which reveals the label on hover and focus.'none' | 'tooltip' | 'inside' | 'outside' | undefinednone
onSelectRuns after the selected item's own `onSelect`.((item: CircularMenuItemData, index: number) => void) | undefinednone
radiusRing radius in pixels. Defaults to the size variant's radius.number | undefinednone
showSpokesDraw a line from the center to each item.boolean | undefinednone
sizeCircularMenuSize | undefinednone
spinRotate the whole ring continuously.boolean | undefinednone
spinDurationSeconds per revolution. Defaults to `12`.number | undefinednone
spinPauseOnHoverDefaults to `true`.boolean | undefinednone
staggerPer-item open/close delay in milliseconds. Defaults to `40`.number | undefinednone
startAngleAngle of the first item in degrees. `0` = 12 o'clock, positive = clockwise.number | undefinednone
sweepArc covered by the items in degrees. `360` closes the ring.number | undefinednone
triggerDefaults to `"click"`. `"hover"` still opens on click and on focus.CircularMenuTriggerMode | undefinednone

Content

PropTypeDefault
childrenReactNodenone
Inherited HTML props
PropTypeDefault
classNamestring | undefinednone
idstring | undefinednone
onClickMouseEventHandler<HTMLDivElement> | undefinednone
styleCSSProperties | undefinednone
titlestring | undefinednone

CircularMenuSpoke

CircularMenuSpokeProps

Variants

PropTypeDefault
appearance
amberbluedefaultdestructiveemeraldglassgradient-bluegradient-rosegradient-violetinfoprimaryrose+6
default
Inherited HTML props
PropTypeDefault
childrenReactNodenone
classNamestring | undefinednone
idstring | undefinednone
onClickMouseEventHandler<HTMLSpanElement> | undefinednone
styleCSSProperties | undefinednone
titlestring | undefinednone

CircularMenuTrigger

CircularMenuTriggerProps

Inherited HTML props
PropTypeDefault
childrenReactNodenone
classNamestring | undefinednone
idstring | undefinednone
onClickMouseEventHandler<HTMLButtonElement> | undefinednone
styleCSSProperties | undefinednone
titlestring | undefinednone

CircularMenuVariant

CircularMenuVariantProps

Behavior

PropTypeDefault
size'md' | 'sm' | 'lg' | null | undefinednone

CircularMenuAnimated

CircularMenuAnimatedProps

animated

State

PropTypeDefault
defaultOpenboolean | undefinednone
onOpenChange((open: boolean) => void) | undefinednone
openboolean | undefinednone

Behavior

PropTypeDefault
animationDefaults to `"pop"`. Forced to `"none"` under `prefers-reduced-motion`.CircularMenuAnimation | undefinednone
appearance'default' | 'blue' | 'slate' | 'zinc' | 'rose' | 'emerald' | 'gradient-blue' | 'success' | 'warning' | 'info' | 'violet' | 'glass' | 'secondary' | 'destructive' | 'amber' | 'primary' | 'gradient-violet' | 'gradient-rose' | undefinednone
boxSizeSquare box edge in pixels. Defaults to `2 * (radius + itemSize / 2)`.number | undefinednone
closeOnEscapeDefaults to `true`.boolean | undefinednone
closeOnOutsideDefaults to `true`.boolean | undefinednone
closeOnSelectDefaults to `true`.boolean | undefinednone
directionCircularMenuDirection | undefinednone
disabledboolean | undefinednone
itemRotationDefaults to `"upright"`, which counter-rotates icons and labels.CircularMenuItemRotation | undefinednone
itemsShorthand data API. Ignored when the root is composed from children.CircularMenuItemData[] | undefinednone
itemSizeItem diameter in pixels. Defaults to the size variant's item size.number | undefinednone
labelTrigger content, and the accessible name of the menu.ReactNodenone
labelPlacementDefaults to `"tooltip"`, which reveals the label on hover and focus.'none' | 'tooltip' | 'inside' | 'outside' | undefinednone
onSelectRuns after the selected item's own `onSelect`.((item: CircularMenuItemData, index: number) => void) | undefinednone
radiusRing radius in pixels. Defaults to the size variant's radius.number | undefinednone
refAllows getting a ref to the component instance. Once the component unmounts, React will set `ref.current` to `null` (or call the ref with `null` if you passed a callback ref).Ref<HTMLDivElement> | undefinednone
showSpokesDraw a line from the center to each item.boolean | undefinednone
sizeCircularMenuSize | undefinednone
spinRotate the whole ring continuously.boolean | undefinednone
spinDurationSeconds per revolution. Defaults to `12`.number | undefinednone
spinPauseOnHoverDefaults to `true`.boolean | undefinednone
staggerPer-item delay in **seconds** (the static entry's `stagger` is in ms).number | undefinednone
startAngleAngle of the first item in degrees. `0` = 12 o'clock, positive = clockwise.number | undefinednone
sweepArc covered by the items in degrees. `360` closes the ring.number | undefinednone
triggerDefaults to `"click"`. `"hover"` still opens on click and on focus.CircularMenuTriggerMode | undefinednone

Content

PropTypeDefault
childrenReactNodenone
Inherited HTML props
PropTypeDefault
classNamestring | undefinednone
idstring | undefinednone
onClickMouseEventHandler<HTMLDivElement> | undefinednone
styleCSSProperties | undefinednone
titlestring | undefinednone

CircularMenuListAnimated

CircularMenuListAnimatedProps

animated

Behavior

PropTypeDefault
animationCircularMenuAnimation | undefinednone
staggerPer-item delay in seconds.number | undefinednone
Inherited HTML props
PropTypeDefault
childrenReactNodenone
classNamestring | undefinednone
idstring | undefinednone
onClickMouseEventHandler<HTMLDivElement> | undefinednone
styleCSSProperties | undefinednone
titlestring | undefinednone
CSS variable overrides

Circular Menu CSS variables

Override these circular-menu variables on :root, a theme selector, or a component wrapper. Layout variables (size, radius, item-size, item-x/-y/-angle, open) are written inline by the component from its props, so change those through the props rather than in CSS.

390 variables

Pattern: --zui-<component>-<slot?>-<variant?>-<property>-<state?>-dark?

:root {
  --zui-circular-menu-amber-item-active-bg: #fde68a;
  --zui-circular-menu-amber-item-bg: #fffbeb;
  --zui-circular-menu-amber-item-border: #fde68a;
  --zui-circular-menu-amber-item-fg: #b45309;
  --zui-circular-menu-amber-item-hover-bg: #fef3c7;
  --zui-circular-menu-amber-spoke-bg: #fde68a;
  --zui-circular-menu-amber-trigger-bg: #d97706;
  --zui-circular-menu-amber-trigger-border: #d97706;
  --zui-circular-menu-amber-trigger-fg: #ffffff;
  --zui-circular-menu-amber-trigger-hover-bg: #b45309;
  --zui-circular-menu-blue-item-active-bg: #bfdbfe;
  --zui-circular-menu-blue-item-bg: #eff6ff;
  --zui-circular-menu-blue-item-border: #bfdbfe;
  --zui-circular-menu-blue-item-fg: #1d4ed8;
  --zui-circular-menu-blue-item-hover-bg: #dbeafe;
  --zui-circular-menu-blue-spoke-bg: #bfdbfe;
  --zui-circular-menu-blue-trigger-bg: #2563eb;
  --zui-circular-menu-blue-trigger-border: #2563eb;
  --zui-circular-menu-blue-trigger-fg: #ffffff;
  --zui-circular-menu-blue-trigger-hover-bg: #1d4ed8;
  --zui-circular-menu-default-item-active-bg: #e4e4e7;
  --zui-circular-menu-default-item-bg: #ffffff;
  --zui-circular-menu-default-item-border: #e4e4e7;
  --zui-circular-menu-default-item-fg: #3f3f46;
  --zui-circular-menu-default-item-hover-bg: #f4f4f5;
  --zui-circular-menu-default-spoke-bg: #e4e4e7;
  --zui-circular-menu-default-trigger-bg: #18181b;
  --zui-circular-menu-default-trigger-border: #18181b;
  --zui-circular-menu-default-trigger-fg: #ffffff;
  --zui-circular-menu-default-trigger-hover-bg: #27272a;
  --zui-circular-menu-destructive-item-active-bg: #fecaca;
  --zui-circular-menu-destructive-item-bg: #fef2f2;
  --zui-circular-menu-destructive-item-border: #fecaca;
  --zui-circular-menu-destructive-item-fg: #b91c1c;
  --zui-circular-menu-destructive-item-hover-bg: #fee2e2;
  --zui-circular-menu-destructive-spoke-bg: #fecaca;
  --zui-circular-menu-destructive-trigger-bg: #dc2626;
  --zui-circular-menu-destructive-trigger-border: #dc2626;
  --zui-circular-menu-destructive-trigger-fg: #ffffff;
  --zui-circular-menu-destructive-trigger-hover-bg: #b91c1c;
  --zui-circular-menu-emerald-item-active-bg: #a7f3d0;
  --zui-circular-menu-emerald-item-bg: #ecfdf5;
  --zui-circular-menu-emerald-item-border: #a7f3d0;
  --zui-circular-menu-emerald-item-fg: #047857;
  --zui-circular-menu-emerald-item-hover-bg: #d1fae5;
  --zui-circular-menu-emerald-spoke-bg: #a7f3d0;
  --zui-circular-menu-emerald-trigger-bg: #059669;
  --zui-circular-menu-emerald-trigger-border: #059669;
  --zui-circular-menu-emerald-trigger-fg: #ffffff;
  --zui-circular-menu-emerald-trigger-hover-bg: #047857;
  --zui-circular-menu-focus-ring: #2563eb;
  --zui-circular-menu-glass-item-active-bg: rgba(255,255,255,0.92);
  --zui-circular-menu-glass-item-bg: rgba(255,255,255,0.62);
  --zui-circular-menu-glass-item-border: rgba(148,163,184,0.35);
  --zui-circular-menu-glass-item-fg: #1e293b;
  --zui-circular-menu-glass-item-hover-bg: rgba(255,255,255,0.82);
  --zui-circular-menu-glass-spoke-bg: rgba(148,163,184,0.45);
  --zui-circular-menu-glass-trigger-bg: rgba(255,255,255,0.72);
  --zui-circular-menu-glass-trigger-border: rgba(255,255,255,0.7);
  --zui-circular-menu-glass-trigger-fg: #1e293b;
  --zui-circular-menu-glass-trigger-hover-bg: rgba(255,255,255,0.9);
  --zui-circular-menu-gradient-blue-item-border: #bfdbfe;
  --zui-circular-menu-gradient-blue-item-fg: #1d4ed8;
  --zui-circular-menu-gradient-blue-item-from: #eff6ff;
  --zui-circular-menu-gradient-blue-item-to: #ecfeff;
  --zui-circular-menu-gradient-blue-spoke-bg: #bfdbfe;
  --zui-circular-menu-gradient-blue-trigger-border: #3b82f6;
  --zui-circular-menu-gradient-blue-trigger-fg: #ffffff;
  --zui-circular-menu-gradient-blue-trigger-from: #2563eb;
  --zui-circular-menu-gradient-blue-trigger-to: #06b6d4;
  --zui-circular-menu-gradient-rose-item-border: #fecdd3;
  --zui-circular-menu-gradient-rose-item-fg: #be123c;
  --zui-circular-menu-gradient-rose-item-from: #fff1f2;
  --zui-circular-menu-gradient-rose-item-to: #fff7ed;
  --zui-circular-menu-gradient-rose-spoke-bg: #fecdd3;
  --zui-circular-menu-gradient-rose-trigger-border: #f43f5e;
  --zui-circular-menu-gradient-rose-trigger-fg: #ffffff;
  --zui-circular-menu-gradient-rose-trigger-from: #e11d48;
  --zui-circular-menu-gradient-rose-trigger-to: #f97316;
  --zui-circular-menu-gradient-violet-item-border: #ddd6fe;
  --zui-circular-menu-gradient-violet-item-fg: #6d28d9;
  --zui-circular-menu-gradient-violet-item-from: #f5f3ff;
  --zui-circular-menu-gradient-violet-item-to: #fdf4ff;
  --zui-circular-menu-gradient-violet-spoke-bg: #ddd6fe;
  --zui-circular-menu-gradient-violet-trigger-border: #8b5cf6;
  --zui-circular-menu-gradient-violet-trigger-fg: #ffffff;
  --zui-circular-menu-gradient-violet-trigger-from: #7c3aed;
  --zui-circular-menu-gradient-violet-trigger-to: #d946ef;
  --zui-circular-menu-info-item-active-bg: #a5f3fc;
  --zui-circular-menu-info-item-bg: #ecfeff;
  --zui-circular-menu-info-item-border: #a5f3fc;
  --zui-circular-menu-info-item-fg: #0e7490;
  --zui-circular-menu-info-item-hover-bg: #cffafe;
  --zui-circular-menu-info-spoke-bg: #a5f3fc;
  --zui-circular-menu-info-trigger-bg: #0891b2;
  --zui-circular-menu-info-trigger-border: #0891b2;
  --zui-circular-menu-info-trigger-fg: #ffffff;
  --zui-circular-menu-info-trigger-hover-bg: #0e7490;
  --zui-circular-menu-item-angle: 0deg;
  --zui-circular-menu-item-index: 0;
  --zui-circular-menu-item-size: 40px;
  --zui-circular-menu-item-x: 0px;
  --zui-circular-menu-item-y: 0px;
  --zui-circular-menu-label-fg: #52525b;
  --zui-circular-menu-open: 1;
  --zui-circular-menu-primary-item-active-bg: #bfdbfe;
  --zui-circular-menu-primary-item-bg: #eff6ff;
  --zui-circular-menu-primary-item-border: #bfdbfe;
  --zui-circular-menu-primary-item-fg: #1d4ed8;
  --zui-circular-menu-primary-item-hover-bg: #dbeafe;
  --zui-circular-menu-primary-spoke-bg: #bfdbfe;
  --zui-circular-menu-primary-trigger-bg: #2563eb;
  --zui-circular-menu-primary-trigger-border: #2563eb;
  --zui-circular-menu-primary-trigger-fg: #ffffff;
  --zui-circular-menu-primary-trigger-hover-bg: #1d4ed8;
  --zui-circular-menu-radius: 132px;
  --zui-circular-menu-ring-offset: #ffffff;
  --zui-circular-menu-rose-item-active-bg: #fecdd3;
  --zui-circular-menu-rose-item-bg: #fff1f2;
  --zui-circular-menu-rose-item-border: #fecdd3;
  --zui-circular-menu-rose-item-fg: #be123c;
  --zui-circular-menu-rose-item-hover-bg: #ffe4e6;
  --zui-circular-menu-rose-spoke-bg: #fecdd3;
  --zui-circular-menu-rose-trigger-bg: #e11d48;
  --zui-circular-menu-rose-trigger-border: #e11d48;
  --zui-circular-menu-rose-trigger-fg: #ffffff;
  --zui-circular-menu-rose-trigger-hover-bg: #be123c;
  --zui-circular-menu-secondary-item-active-bg: #e2e8f0;
  --zui-circular-menu-secondary-item-bg: #f8fafc;
  --zui-circular-menu-secondary-item-border: #e2e8f0;
  --zui-circular-menu-secondary-item-fg: #334155;
  --zui-circular-menu-secondary-item-hover-bg: #f1f5f9;
  --zui-circular-menu-secondary-spoke-bg: #e2e8f0;
  --zui-circular-menu-secondary-trigger-bg: #475569;
  --zui-circular-menu-secondary-trigger-border: #475569;
  --zui-circular-menu-secondary-trigger-fg: #ffffff;
  --zui-circular-menu-secondary-trigger-hover-bg: #334155;
  --zui-circular-menu-size: 300px;
  --zui-circular-menu-slate-item-active-bg: #e2e8f0;
  --zui-circular-menu-slate-item-bg: #f8fafc;
  --zui-circular-menu-slate-item-border: #e2e8f0;
  --zui-circular-menu-slate-item-fg: #334155;
  --zui-circular-menu-slate-item-hover-bg: #f1f5f9;
  --zui-circular-menu-slate-spoke-bg: #e2e8f0;
  --zui-circular-menu-slate-trigger-bg: #475569;
  --zui-circular-menu-slate-trigger-border: #475569;
  --zui-circular-menu-slate-trigger-fg: #ffffff;
  --zui-circular-menu-slate-trigger-hover-bg: #334155;
  --zui-circular-menu-spin-duration: 12s;
  --zui-circular-menu-spoke-width: 2px;
  --zui-circular-menu-stagger: 40ms;
  --zui-circular-menu-success-item-active-bg: #a7f3d0;
  --zui-circular-menu-success-item-bg: #ecfdf5;
  --zui-circular-menu-success-item-border: #a7f3d0;
  --zui-circular-menu-success-item-fg: #047857;
  --zui-circular-menu-success-item-hover-bg: #d1fae5;
  --zui-circular-menu-success-spoke-bg: #a7f3d0;
  --zui-circular-menu-success-trigger-bg: #059669;
  --zui-circular-menu-success-trigger-border: #059669;
  --zui-circular-menu-success-trigger-fg: #ffffff;
  --zui-circular-menu-success-trigger-hover-bg: #047857;
  --zui-circular-menu-tooltip-bg: #ffffff;
  --zui-circular-menu-tooltip-border: #e4e4e7;
  --zui-circular-menu-transition-duration: 220ms;
  --zui-circular-menu-trigger-size: 64px;
  --zui-circular-menu-violet-item-active-bg: #ddd6fe;
  --zui-circular-menu-violet-item-bg: #f5f3ff;
  --zui-circular-menu-violet-item-border: #ddd6fe;
  --zui-circular-menu-violet-item-fg: #6d28d9;
  --zui-circular-menu-violet-item-hover-bg: #ede9fe;
  --zui-circular-menu-violet-spoke-bg: #ddd6fe;
  --zui-circular-menu-violet-trigger-bg: #7c3aed;
  --zui-circular-menu-violet-trigger-border: #7c3aed;
  --zui-circular-menu-violet-trigger-fg: #ffffff;
  --zui-circular-menu-violet-trigger-hover-bg: #6d28d9;
  --zui-circular-menu-warning-item-active-bg: #fde68a;
  --zui-circular-menu-warning-item-bg: #fffbeb;
  --zui-circular-menu-warning-item-border: #fde68a;
  --zui-circular-menu-warning-item-fg: #b45309;
  --zui-circular-menu-warning-item-hover-bg: #fef3c7;
  --zui-circular-menu-warning-spoke-bg: #fde68a;
  --zui-circular-menu-warning-trigger-bg: #d97706;
  --zui-circular-menu-warning-trigger-border: #d97706;
  --zui-circular-menu-warning-trigger-fg: #ffffff;
  --zui-circular-menu-warning-trigger-hover-bg: #b45309;
  --zui-circular-menu-zinc-item-active-bg: #e4e4e7;
  --zui-circular-menu-zinc-item-bg: #fafafa;
  --zui-circular-menu-zinc-item-border: #e4e4e7;
  --zui-circular-menu-zinc-item-fg: #3f3f46;
  --zui-circular-menu-zinc-item-hover-bg: #f4f4f5;
  --zui-circular-menu-zinc-spoke-bg: #e4e4e7;
  --zui-circular-menu-zinc-trigger-bg: #52525b;
  --zui-circular-menu-zinc-trigger-border: #52525b;
  --zui-circular-menu-zinc-trigger-fg: #ffffff;
  --zui-circular-menu-zinc-trigger-hover-bg: #3f3f46;
}

/* Dark theme variables follow the same names with -dark appended. */
.dark {
  --zui-circular-menu-amber-item-active-bg-dark: #92400e;
  --zui-circular-menu-amber-item-bg-dark: #451a03;
  /* ...same variables with -dark at the end */
}

How it works

A square box owns the ring's coordinate space. The center trigger sits at its middle, and each item is placed at a solved polar offset: 0 degrees points at 12 o'clock and positive angles run clockwise. Offsets are written as CSS custom properties, so items fan out from the center when the ring opens and collapse back into it when it closes. When boxSize is omitted it is derived as 2 * (radius + itemSize / 2), which is the smallest square that fits the ring plus one disc of overhang.

Rings and arcs

sweep controls how much of the circle the items cover and direction controls which way they run. A closed ring spaces items evenly all the way around; an open arc places the first item at startAngle and the last one exactly at the end of the arc. Combined with radius and itemSize, this covers everything from a tight six-item wheel to a wide quarter-circle fan anchored in a corner.

Compound API

Use the items prop for the shorthand, or compose the parts: CircularMenu.Root, CircularMenu.Trigger, CircularMenu.List, CircularMenu.Item, CircularMenu.ItemIcon, CircularMenu.ItemLabel, and CircularMenu.Spoke. The root owns open state, geometry, and keyboard handling; the list solves one position per child and publishes it to that item, so items never take an index prop. Items with an href render as anchors.

Rotation

spin rotates the whole ring as one rigid body, spinDuration sets the seconds per revolution, and spinPauseOnHover pauses it while the pointer is over the menu. itemRotation decides whether items counter-rotate to stay upright or follow the ring around. trigger="always" keeps the ring open, which turns the component into a decorative orbit.

Keyboard interaction

Tab to the trigger, then press Enter, Space, or an arrow key to open. Arrow Right and Down move to the next item, Arrow Left and Up to the previous one, and both wrap around the ring. Home and End jump to the first and last item. Escape closes the ring and returns focus to the trigger, and tabbing away closes it too.

Accessibility

The trigger is a semantic button and the ring uses menu and menuitem roles with a roving tabindex, so only one item is in the tab order at a time. A closed ring is inert and aria-hidden. trigger="hover" also opens on click and on focus, because hover alone is not operable by keyboard or touch. Labels can be revealed on hover and focus, placed outside the disc, or hidden visually while still naming the item. Reduced-motion preferences are respected throughout.

FAQ

Can the circular menu render a half circle or a quarter arc instead of a full ring?

Yes. Set sweep to the arc you want in degrees and startAngle to aim it. A sweep of 360 divides the circle by the item count so the first and last item do not collide; any smaller sweep divides by count - 1 so both endpoints land exactly on the arc ends.

Does the circular menu work with a keyboard?

Yes. The center trigger is a real button with aria-haspopup and aria-expanded, and the ring is a role="menu" with roving tabindex. Arrow keys move around the ring and wrap, Home and End jump to the first and last item, Enter or Space selects, and Escape closes and returns focus to the trigger. The closed ring is inert, so it never takes focus.

How does the continuous rotation work without framer-motion?

The static entry rotates the ring with a CSS animation and counter-rotates each icon and label in the opposite direction, so glyphs stay upright. Hovering pauses it and prefers-reduced-motion stops it. The animated entry does the same with a framer-motion motion value, so a spinning ring never re-renders React.

Can I customize the appearance?

Yes. The component ships 18 appearance palettes (default, primary, secondary, success, destructive, warning, info, several accent colors, three gradients, and glass) across the trigger, item discs, and spokes, plus sm/md/lg sizes. Every color is a --zui-circular-menu-* CSS variable you can override, and layout values like radius and item size are both props and custom properties.