Orbit Menu
A dial navigation menu: entries ride a huge off-canvas circle, and the one at the focal point spells out its title. Spin it by wheel, drag or keys.
A free, copy-paste navigation menus component for React, Next.js — built with Tailwind CSS, installable with the shadcn CLI.
Configuration
Tweak these live from theConfigurecontrol in the preview.
Interaction Type
- Scroll, drag or tap a number to spin the dial
- The entry at the focal point spells out its title
- Arrow keys step through, Escape closes
Dependency
No dependencies — plain React. Just drop the file in.
Point the CLI here once
A namespace is a local alias, so the CLI has to be told what @harshdev-ui resolves to. One command, once per project:
npx shadcn registry add "@harshdev-ui=https://ui.harshpandav.dev/r/{name}.json"After that every component installs by name. To skip it entirely, pass the URL straight to the CLI — shadcn add https://ui.harshpandav.dev/r/orbit-menu.json — or copy the file from Source code below.
[ shadcn CLI 3.0 registry ]…or add the component in one command
Install via CLI
pnpm dlx shadcn add @harshdev-ui/orbit-menuHow to use
Give Orbit Menu a list of entries — a label and an optional description each — and it lays them along a circle whose centre sits off-canvas, so only the arc crosses the screen. Each number is rotated to its own tangent, and the one that reaches the focal point becomes active and spells out its title beside it.
The menu button opens it with one of four reveals — iris, curtain, sweep or fade — and the entries then stagger in along the arc, ordered outward from the focused one. Spin it however you like: the wheel, a vertical drag, the arrow keys, or by tapping a number straight off the arc. Escape closes it. Geometry is measured from the box rather than the viewport, so it drops into a full page or a framed card equally well. Under 640px it re-forms as a row — the arc runs down the near edge with the marker, number, title and description reading across it — and the entries pack closer so more of the dial stays on screen.
Basic usage
import { OrbitMenu } from "@/components/harsh-ui/orbit-menu";
const items = [
{ label: "Overview", description: "Where the whole thing starts" },
{ label: "Materials", description: "Anodised aluminium and woven glass" },
{ label: "Interaction", description: "Persistent information and multi-touch" },
];
const Demo = () => (
<OrbitMenu items={items} brand="WOVE" className="h-screen w-full" />
);Tighter dial, heavier spin
// less angle between entries + a lower chase = a slower, weightier wheel
<OrbitMenu items={items} spread={18} ease={0.07} accent="#e11d48" />With a CTA and footer
<OrbitMenu
items={items}
action="Apply Now"
footerLeft="Made by — Polyera"
footerRight="© Polyera Corporation"
defaultOpen
/>Demo
import { OrbitMenuDemo } from "@/components/demos/orbit-menu-demo";
const Demo = () => (
<div className="h-screen w-full">
<OrbitMenuDemo />
</div>
);Props
Notes
- Hovering a linked title rolls it: the label slides up out of a clip while a duplicate parked a line below rises into its place, and the arrow lifts with it.
- The arc swings in on open and the entries stagger along it, then each change wipes the new title up behind a clip — so a change always announces itself rather than quietly swapping.
- Nothing loops forever: the arc drifts only while the dial is moving, because its rotation is driven from the same eased position the entries are, so it travels as you scroll and stops dead when you stop. The wave is one periodic path that gets rotated rather than redrawn, and the bar ruler is only built across the span that actually crosses the stage.
- Opening and closing are one transition played in reverse. Iris and curtain clip the stage — the iris circle is anchored to the menu button, so the overlay literally grows out of the control you pressed — while sweep and fade scale it. The arc, the entries and the footer then come in on their own offsets, so nothing arrives all at once.
- The dial position is a float eased toward the target index each frame, and every entry's transform is written straight to its node — so React only re-renders when the selection actually changes, not on every frame of the spin.
- The circle is deliberately larger than the box and centred off-canvas, so only its arc sweeps through. Each entry is rotated by its own angle, which is what makes the numbers lean into the curve instead of sitting upright on it.
- Entries fade out with angular distance from the focal point, so the dial reads as depth rather than a flat list, and the arc dot under the active entry grows to mark it.
- Geometry comes from the measured box, never the viewport, so it lays out the same in a full page and in the CSS-scaled grid-card preview. Under 640px the whole thing re-forms as a row against the near edge, and the angle between entries tightens so two still fit either side of the focus.
- Wheel, drag, arrow keys and tapping a number all drive the same target, and a drag of one radius-arc walks exactly one entry so the wheel tracks your hand.
- prefers-reduced-motion snaps straight to the selection instead of easing, and the loop parks a few frames after the dial settles.
- Inspired by the arc navigation on the Wove site by Polyera. The dial idea is theirs; the geometry, the eased float, the drag/wheel/keyboard handling and the responsive fallback are this version's.
Source code
The complete orbit-menu.tsx — toggle TSX / JSX to copy it in either language.
"use client";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
type ReactNode,
} from "react";
/**
* Harsh Dev UI · Developed by Harsh Pandav
* https://harshpandav.dev · hello@harshpandav.dev
*
* OrbitMenu
* A navigation overlay built on a dial. The entries ride a circle whose centre
* sits off-canvas, so only the arc crosses the screen; each one is rotated to
* its own tangent, and whichever reaches the focal point becomes the active
* entry and spells out its title. Spin it with the wheel, by dragging, with the
* arrow keys, or by picking a number straight off the arc.
*
* The menu button opens it with one of four reveals — an iris wiping out from
* the button itself, a curtain, a fade or a sweep — after which the entries
* stagger in along the arc, ordered outward from the focused one.
*
* The dial position is a float eased toward the target index every frame and the
* transforms are written straight to the nodes, so React only re-renders when
* the selection changes. Geometry is measured from the box rather than the
* viewport: on a narrow screen the radius tightens and the title drops under the
* number. Zero dependencies — no Motion, no GSAP. Honours prefers-reduced-motion
* and closes on Escape.
*/
export type OrbitMenuItem = {
label: string;
description?: string;
href?: string;
};
export type OrbitMenuEffect = "iris" | "curtain" | "fade" | "sweep";
export type OrbitMenuArc = "line" | "dotted" | "wave" | "bars";
export type OrbitMenuProps = {
items: OrbitMenuItem[];
brand?: ReactNode;
action?: ReactNode;
footerLeft?: ReactNode;
footerRight?: ReactNode;
children?: ReactNode;
openEffect?: OrbitMenuEffect;
arc?: OrbitMenuArc;
duration?: number;
stagger?: number;
spread?: number;
ease?: number;
defaultIndex?: number;
menuSide?: "left" | "right";
barOffset?: number;
accent?: string;
accentArc?: boolean;
accentNumber?: boolean;
defaultOpen?: boolean;
autoPlay?: boolean;
className?: string;
};
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
const pad = (n: number) => String(n).padStart(2, "0");
export function OrbitMenu({
items,
brand = "HARSH DEV UI",
action,
footerLeft,
footerRight,
children,
openEffect = "iris",
arc = "line",
duration = 720,
stagger = 55,
spread = 26,
ease = 0.14,
defaultIndex = 0,
menuSide = "right",
barOffset = 0,
accent = "#6d5efc",
accentArc = false,
accentNumber = false,
defaultOpen = false,
autoPlay = false,
className = "",
}: OrbitMenuProps) {
const N = items.length;
const start0 = clamp(Math.round(defaultIndex), 0, Math.max(0, N - 1));
const [open, setOpen] = useState(defaultOpen || autoPlay);
// autoPlay can flip on a live instance (a preview card toggles it on hover),
// so the menu follows it instead of reading it only once at mount.
const [prevAutoPlay, setPrevAutoPlay] = useState(autoPlay);
if (prevAutoPlay !== autoPlay) {
setPrevAutoPlay(autoPlay);
setOpen(defaultOpen || autoPlay);
}
const [active, setActive] = useState(start0);
const [reduce, setReduce] = useState(false);
const [box, setBox] = useState({ w: 0, h: 0 });
const [runKey, setRunKey] = useState(0);
const stageRef = useRef<HTMLDivElement>(null);
const arcRef = useRef<SVGGElement>(null);
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
const dotRefs = useRef<(SVGCircleElement | null)[]>([]);
const target = useRef(start0);
const pos = useRef(start0);
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
const update = () => setReduce(mq.matches);
update();
mq.addEventListener("change", update);
return () => mq.removeEventListener("change", update);
}, []);
useEffect(() => {
const el = stageRef.current;
if (!el) return;
const read = () => setBox({ w: el.clientWidth, h: el.clientHeight });
const ro = new ResizeObserver(read);
read();
ro.observe(el);
return () => ro.disconnect();
}, []);
const narrow = box.w > 0 && box.w < 640;
const radius = Math.max(box.h * (narrow ? 1.08 : 0.62), box.w * (narrow ? 2.2 : 0.3)) || 400;
const dir = menuSide === "right" ? -1 : 1;
const inset = narrow ? 0.28 : 0.3;
const focusX = box.w * (dir === 1 ? inset : 1 - inset);
const cx = focusX - dir * radius;
const cy = box.h / 2;
const spreadDeg = narrow ? spread * 0.32 : spread;
const step = (spreadDeg * Math.PI) / 180;
const ms = reduce ? 0 : Math.max(120, duration);
const irisX = menuSide === "left" ? "2.6rem" : "calc(100% - 2.6rem)";
const textured = arc === "dotted" || arc === "wave";
const arcAlpha = accentArc ? 1.9 : 1;
const labelGap = narrow ? 18 : 44;
const titleGap = labelGap + (narrow ? 58 : 92);
const wavePath = useMemo(() => {
if (arc !== "wave" || !radius) return "";
const steps = narrow ? 170 : 250;
const amp = Math.max(3, radius * 0.013);
let d = "";
for (let i = 0; i <= steps; i++) {
const t = (i / steps) * Math.PI * 2;
const r = radius + amp * Math.sin(44 * t);
d += `${i ? "L" : "M"}${(cx + dir * r * Math.cos(t)).toFixed(1)} ${(cy + r * Math.sin(t)).toFixed(1)}`;
}
return `${d}Z`;
}, [arc, radius, cx, cy, narrow, dir]);
const bars = useMemo(() => {
if (arc !== "bars" || !radius) return [];
const stepDeg = Math.max(2, (26 / radius) * (180 / Math.PI));
const out: { x1: number; y1: number; x2: number; y2: number; long: boolean }[] = [];
for (let n = 0; n * stepDeg < 360; n++) {
const deg = n * stepDeg;
const t = (deg * Math.PI) / 180;
const long = n % 5 === 0;
const r1 = radius - (long ? 11 : 5);
const r2 = radius + (long ? 11 : 5);
const r2d = (v: number) => Math.round(v * 100) / 100;
out.push({
x1: r2d(cx + dir * r1 * Math.cos(t)),
y1: r2d(cy + r1 * Math.sin(t)),
x2: r2d(cx + dir * r2 * Math.cos(t)),
y2: r2d(cy + r2 * Math.sin(t)),
long,
});
}
return out;
}, [arc, radius, cx, cy, dir]);
const goTo = useCallback(
(i: number) => {
target.current = clamp(i, 0, N - 1);
},
[N],
);
const toggle = useCallback(() => {
if (autoPlay) return;
setOpen((o) => {
if (!o) {
target.current = start0;
pos.current = start0;
setActive(start0);
setRunKey((k) => k + 1);
}
return !o;
});
}, [autoPlay, start0]);
useEffect(() => {
if (!open || box.w === 0) return;
let raf = 0;
let idle = 0;
const paint = () => {
const p = pos.current;
for (let i = 0; i < N; i++) {
const a = (i - p) * step;
const away = Math.abs(a);
const rOut = radius + labelGap;
const x = cx + dir * rOut * Math.cos(a);
const y = cy + rOut * Math.sin(a);
const node = itemRefs.current[i];
if (node) {
node.style.transform = `translate3d(${x}px, ${y}px, 0) translate(-50%, -50%) rotate(${(dir * a * 180) / Math.PI}deg)`;
node.style.opacity = String(clamp(1 - away * 0.5, 0, 1));
node.style.zIndex = away < 0.05 ? "2" : "1";
}
const dot = dotRefs.current[i];
if (dot) {
const focused = away < 0.12;
dot.setAttribute("cx", String(cx + dir * radius * Math.cos(a)));
dot.setAttribute("cy", String(cy + radius * Math.sin(a)));
dot.setAttribute("r", String(focused ? 4.5 : 2.5));
dot.setAttribute("fill", focused ? accent : "currentColor");
dot.setAttribute("fill-opacity", focused ? "1" : "0.4");
}
}
if (arcRef.current) {
arcRef.current.style.transform = `rotate(${dir * p * spreadDeg * 0.55}deg)`;
}
};
const tick = () => {
raf = 0;
const d = target.current - pos.current;
pos.current += d * (reduce ? 1 : clamp(ease, 0.02, 1));
if (Math.abs(d) < 0.0005) pos.current = target.current;
paint();
const next = Math.round(pos.current);
setActive((prev) => (prev === next ? prev : next));
idle = Math.abs(d) < 0.0005 ? idle + 1 : 0;
if (idle < 3) raf = requestAnimationFrame(tick);
};
const start = () => {
idle = 0;
if (!raf) raf = requestAnimationFrame(tick);
};
paint();
start();
const onWheel = (e: WheelEvent) => {
e.preventDefault();
goTo(Math.round(target.current) + (e.deltaY > 0 ? 1 : -1));
start();
};
let dragging = false;
let lastY = 0;
let from = 0;
const onDown = (e: PointerEvent) => {
dragging = true;
lastY = e.clientY;
from = target.current;
};
const onMove = (e: PointerEvent) => {
if (!dragging) return;
target.current = clamp(from + (lastY - e.clientY) / (radius * step), 0, N - 1);
start();
};
const onUp = () => {
if (!dragging) return;
dragging = false;
goTo(Math.round(target.current));
start();
};
const onKey = (e: KeyboardEvent) => {
const t = e.target as HTMLElement | null;
if (t && (/^(INPUT|SELECT|TEXTAREA)$/.test(t.tagName) || t.isContentEditable)) return;
if (e.key === "ArrowDown" || e.key === "ArrowRight") {
goTo(Math.round(target.current) + 1);
start();
} else if (e.key === "ArrowUp" || e.key === "ArrowLeft") {
goTo(Math.round(target.current) - 1);
start();
} else if (e.key === "Escape" && !autoPlay) {
setOpen(false);
}
};
const el = stageRef.current;
el?.addEventListener("wheel", onWheel, { passive: false });
el?.addEventListener("pointerdown", onDown);
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
window.addEventListener("keydown", onKey);
let cycle: ReturnType<typeof setInterval> | undefined;
if (autoPlay) {
let i = start0;
cycle = setInterval(() => {
i = (i + 1) % N;
goTo(i);
start();
}, 2200);
}
return () => {
if (raf) cancelAnimationFrame(raf);
if (cycle) clearInterval(cycle);
el?.removeEventListener("wheel", onWheel);
el?.removeEventListener("pointerdown", onDown);
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
window.removeEventListener("keydown", onKey);
};
}, [open, box.w, box.h, N, step, radius, cx, cy, ease, reduce, autoPlay, goTo, start0, dir, spreadDeg, accent, labelGap]);
const current = items[clamp(active, 0, N - 1)];
const titleRoom = Math.max(
120,
(dir === 1 ? box.w - focusX - titleGap : focusX - titleGap) - 24,
);
const capFs = narrow ? 22 : 64;
const fitFs = titleRoom / Math.max(5, (current?.label.length ?? 8) * 0.58);
const titleFs = Math.max(narrow ? 15 : 24, Math.min(capFs, fitFs));
const reveal: CSSProperties =
openEffect === "iris"
? {
clipPath: open
? `circle(150% at ${irisX} ${barOffset + 42}px)`
: `circle(0% at ${irisX} ${barOffset + 42}px)`,
transition: `clip-path ${ms}ms cubic-bezier(.76,0,.24,1)`,
}
: openEffect === "curtain"
? {
clipPath: open ? "inset(0 0 0% 0)" : "inset(0 0 100% 0)",
transition: `clip-path ${ms}ms cubic-bezier(.76,0,.24,1)`,
}
: {
opacity: open ? 1 : 0,
transform: open ? "scale(1)" : openEffect === "sweep" ? "scale(1.06)" : "scale(1.02)",
transition: `opacity ${ms}ms ease, transform ${ms}ms cubic-bezier(.22,1,.36,1)`,
};
return (
<div className={`relative overflow-hidden bg-background text-foreground ${className}`}>
{children}
<div
className={`pointer-events-none absolute inset-x-0 top-0 z-30 flex items-center gap-4 px-5 py-5 sm:px-8 sm:py-6 ${
menuSide === "left" ? "flex-row-reverse justify-end" : "justify-between"
}`}
style={barOffset ? { paddingTop: barOffset } : undefined}
>
<span className="text-[0.65rem] font-semibold uppercase tracking-[0.3em] sm:text-sm sm:tracking-[0.4em]">
{brand}
</span>
<button
type="button"
onClick={toggle}
aria-expanded={open}
aria-label={open ? "Close menu" : "Open menu"}
className={`pointer-events-auto relative grid size-10 place-items-center rounded-full transition-colors hover:bg-foreground/10 ${menuSide === "left" ? "-ml-2" : "-mr-1"}`}
>
<span
className="absolute h-px bg-current transition-all duration-500 ease-[cubic-bezier(.76,0,.24,1)]"
style={{ width: "1.5rem", transform: open ? "rotate(45deg)" : "translateY(-4px)" }}
/>
<span
className="absolute h-px bg-current transition-all duration-500 ease-[cubic-bezier(.76,0,.24,1)]"
style={{
width: open ? "1.5rem" : "1.05rem",
marginLeft: open ? 0 : "0.45rem",
transform: open ? "rotate(-45deg)" : "translateY(4px)",
}}
/>
</button>
</div>
<div
ref={stageRef}
className="absolute inset-0 z-20 select-none bg-background"
style={{
...reveal,
pointerEvents: open ? "auto" : "none",
touchAction: "none",
cursor: open ? "grab" : "auto",
}}
aria-hidden={!open}
>
<svg className="pointer-events-none absolute inset-0 h-full w-full">
<g
key={`arc-${runKey}`}
style={{
color: accentArc ? accent : undefined,
transformOrigin: `${cx}px ${cy}px`,
animation: reduce || !open ? undefined : `orbit-arc ${Math.round(ms * 1.1)}ms cubic-bezier(.22,1,.36,1) both`,
opacity: open ? 1 : 0,
transition: `opacity ${ms}ms ease`,
}}
>
<g
ref={arcRef}
style={{
transformOrigin: `${cx}px ${cy}px`,
transition: reduce ? undefined : "transform .45s cubic-bezier(.22,1,.36,1)",
willChange: textured ? "transform" : undefined,
}}
>
{arc === "wave" ? (
<path d={wavePath} fill="none" stroke="currentColor" strokeOpacity={0.22 * arcAlpha} strokeWidth={1} />
) : arc === "bars" ? (
bars.map((b, i) => (
<line
key={i}
x1={b.x1}
y1={b.y1}
x2={b.x2}
y2={b.y2}
stroke="currentColor"
strokeOpacity={(b.long ? 0.3 : 0.14) * arcAlpha}
strokeWidth={1}
/>
))
) : (
<circle
cx={cx}
cy={cy}
r={radius}
fill="none"
stroke="currentColor"
strokeOpacity={(arc === "dotted" ? 0.34 : 0.16) * arcAlpha}
strokeWidth={arc === "dotted" ? 1.6 : 1}
strokeLinecap="round"
strokeDasharray={arc === "dotted" ? "0.5 13" : undefined}
/>
)}
</g>
</g>
{items.map((item, i) => (
<circle
key={item.label}
ref={(el) => void (dotRefs.current[i] = el)}
r={2.5}
fill="currentColor"
fillOpacity={0.4}
/>
))}
</svg>
<div key={runKey} className="absolute inset-0">
{items.map((item, i) => (
<div
key={item.label}
ref={(el) => void (itemRefs.current[i] = el)}
className="absolute left-0 top-0 will-change-transform"
>
<Entry
href={i === active ? item.href : undefined}
onSelect={() => goTo(i)}
tabIndex={open ? 0 : -1}
className="block cursor-pointer whitespace-nowrap font-semibold leading-none tracking-tight no-underline"
style={{
fontSize: narrow ? 40 : titleFs,
animation:
reduce || !open
? undefined
: `${openEffect === "sweep" ? "orbit-sweep" : "orbit-in"} ${Math.round(ms * 0.8)}ms cubic-bezier(.22,1,.36,1) ${
Math.abs(i - start0) * stagger + Math.round(ms * 0.25)
}ms both`,
}}
>
<span
style={
i === active
? accentNumber
? { color: accent }
: undefined
: {
WebkitTextFillColor: "transparent",
WebkitTextStrokeWidth: "1px",
WebkitTextStrokeColor: accentArc ? accent : "currentColor",
opacity: accentArc ? 0.6 : 0.34,
}
}
>
{pad(i + 1)}
</span>
</Entry>
</div>
))}
</div>
<div
className="pointer-events-none absolute z-10"
style={
dir === 1
? {
left: focusX + titleGap,
top: cy,
transform: "translateY(-50%)",
maxWidth: Math.max(120, box.w - focusX - titleGap - 24),
}
: {
right: box.w - focusX + titleGap,
top: cy,
transform: "translateY(-50%)",
maxWidth: Math.max(120, focusX - titleGap - 24),
textAlign: "right",
}
}
>
<h2
className="font-semibold leading-none tracking-tight"
style={{ fontSize: titleFs }}
>
{(() => {
const slide = (
<span className="-mb-[0.14em] block overflow-hidden pb-[0.14em]">
<span
key={`${runKey}-${current?.label}`}
className="block"
style={{
animation: reduce
? undefined
: "orbit-slide .55s cubic-bezier(.22,1,.36,1) both",
}}
>
<Roll label={current?.label ?? ""} />
</span>
</span>
);
const arrow = (
<span
aria-hidden
className="shrink-0 pr-[0.12em] text-[0.4em] transition-transform duration-500 ease-[cubic-bezier(.76,0,.24,1)] group-hover/roll:-translate-y-1 group-hover/roll:translate-x-1 motion-reduce:transition-none"
style={{ color: accent }}
>
↗
</span>
);
const inner = (
<>
<span className="min-w-0">{slide}</span>
{current?.href && arrow}
</>
);
return current?.href ? (
<a
href={current.href}
className="group/roll pointer-events-auto inline-flex max-w-full items-baseline gap-2 no-underline"
>
{inner}
</a>
) : (
<span className="group/roll pointer-events-auto inline-flex max-w-full items-baseline">
{inner}
</span>
);
})()}
</h2>
{current?.description && (
<span className="absolute inset-x-0 top-full mt-1.5 block overflow-hidden sm:mt-2">
<span
key={`${runKey}-${current.label}-d`}
className="block break-words text-xs text-foreground/50 sm:text-sm"
style={{
animation: reduce
? undefined
: "orbit-slide .55s .07s cubic-bezier(.22,1,.36,1) both",
}}
>
{current.description}
</span>
</span>
)}
</div>
{(footerLeft || footerRight || action) && (
<div
className="absolute inset-x-0 bottom-0 flex items-end justify-between gap-4 px-5 py-5 text-[0.6rem] text-foreground/50 sm:px-8 sm:py-6 sm:text-[0.7rem]"
style={{
opacity: open ? 1 : 0,
transition: `opacity ${ms}ms ease ${open ? Math.round(ms * 0.4) : 0}ms`,
}}
>
<span className="pb-1">{footerLeft}</span>
<div className="flex flex-col items-end gap-3">
{action && (
<button
type="button"
className="pointer-events-auto rounded-full px-5 py-2.5 text-xs font-medium text-white transition-transform hover:scale-[1.04]"
style={{ background: accent }}
>
{action}
</button>
)}
<span className="hidden sm:inline">{footerRight}</span>
</div>
</div>
)}
</div>
<style>{`
@keyframes orbit-rise{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:none}}
@keyframes orbit-in{from{opacity:0;transform:translateX(34px) scale(.86)}to{opacity:1;transform:none}}
@keyframes orbit-sweep{from{opacity:0;transform:translateY(46px) rotate(-14deg)}to{opacity:1;transform:none}}
@keyframes orbit-arc{from{opacity:0;transform:rotate(-16deg) scale(.9)}to{opacity:1;transform:none}}
@keyframes orbit-slide{from{transform:translateY(108%)}to{transform:none}}
`}</style>
</div>
);
}
function Roll({ label }: { label: string }) {
const shift = "duration-500 ease-[cubic-bezier(.76,0,.24,1)] motion-reduce:transition-none";
return (
<span className="relative -mb-[0.14em] block min-w-0 overflow-hidden pb-[0.14em]">
<span className={`block transition-transform group-hover/roll:-translate-y-[105%] ${shift}`}>
{label}
</span>
<span
aria-hidden
className={`absolute inset-x-0 top-0 block translate-y-[105%] transition-transform group-hover/roll:translate-y-0 ${shift}`}
>
{label}
</span>
</span>
);
}
function Entry({
href,
onSelect,
children,
...rest
}: {
href?: string;
onSelect: () => void;
children: ReactNode;
tabIndex: number;
className: string;
style: CSSProperties;
}) {
if (href) {
return (
<a href={href} {...rest}>
{children}
</a>
);
}
return (
<button type="button" onClick={onSelect} {...rest}>
{children}
</button>
);
}
Keep in mind
Some components here are recreations of the best interactions out there. I don't claim to be the original creator — this is my attempt to study, replicate, and often add a few extra features. I've tried to credit everyone; if I missed something, let me know.
Contact
Found a bug or an issue? Drop a mail — hello@harshpandav.dev
License & Usage
- Free to use and modify in personal and commercial projects.
- Attribution to Harsh Dev UI is appreciated but not required.
- MIT licensed.