Motion & effects · Adapted
Orbital timeline
A radial project timeline adapted from Jatin Yadav's MIT-labelled 21st.dev component.
Live preview
Try it in context
Customize preview
Source delivery
v0.3.0Usage & source
"use client";
import type { CSSProperties } from "react";
import { OrbitalTimeline } from "./components/stepkit/OrbitalTimeline";
import "./components/stepkit/styles.css";
import "./components/stepkit/adapted-showcase.css";
const themeStyle = {"--stepkit-bg":"#111217","--stepkit-surface":"#1b1c24","--stepkit-surface-muted":"#22232d","--stepkit-fg":"#f6f5fb","--stepkit-muted":"#a4a6b4","--stepkit-border":"#343641","--stepkit-border-strong":"#565866","--stepkit-code":"#20212a","--stepkit-shadow":"0 24px 70px rgba(0, 0, 0, .34)","--stepkit-accent":"#2563eb","--stepkit-accent-strong":"#1d4ed8","--stepkit-accent-soft":"#1f356b","--stepkit-accent-on-soft":"#a9c4ff","--stepkit-accent-text":"#ffffff","--stepkit-error":"#ff9ba9","--stepkit-placeholder":"#b8bac6","--stepkit-radius":"16px","--stepkit-space":"16px","--stepkit-control-height":"44px","--stepkit-font-sans":"Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif"} as CSSProperties;
const timelineData: import("./components/stepkit/OrbitalTimeline").TimelineItem[] = [{ id: 1, title: "Research", date: "Mar 04", content: "Turn the first signal into a useful direction.", category: "Research", relatedIds: [2], status: "completed", energy: 86 }, { id: 2, title: "Design", date: "Mar 11", content: "Make the idea legible before it becomes large.", category: "Design", relatedIds: [1, 3], status: "in-progress", energy: 68 }, { id: 3, title: "Build", date: "Mar 18", content: "Carry the shape into a working surface.", category: "Build", relatedIds: [2, 4], status: "in-progress", energy: 54 }, { id: 4, title: "Test", date: "Mar 25", content: "Check the details that make the experience hold together.", category: "Testing", relatedIds: [3, 5], status: "pending", energy: 35 }, { id: 5, title: "Release", date: "Apr 01", content: "Give the finished idea a clear path into the world.", category: "Release", relatedIds: [4], status: "pending", energy: 20 }];
export default function Example() {
return (
<div className="stepkit-theme" data-stepkit-preset="modern" style={themeStyle}>
<OrbitalTimeline
timelineData={timelineData}
title={"Project orbit"}
paused={false}
/>
</div>
);
}Dependencies
[email protected] [email protected]
/*
* Stepkit 0.3.0
* Copyright (c) 2026 Vladislav Stepanov
*
* Original Stepkit portions in this release are licensed under the MIT License. Adapted portions identify their upstream licence and attribution in the copied source and in the entry-specific upstream notice files.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
*
* MIT License
*
* Upstream attribution: Jatin Yadav, Radial Orbital Timeline, as published at
* https://21st.dev/@jatin-yadav05/components/radial-orbital-timeline
*
* The upstream component page labels this component MIT License and does not
* provide a separate copyright line. No copyright holder or year is added here.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
*/
"use client";
import { useEffect, useRef, useState, type ComponentType, type CSSProperties, type MouseEvent } from "react";
import "./styles.css";
import "./adapted-showcase.css";
/**
* Adapted from Jatin Yadav's Radial Orbital Timeline (MIT label on 21st.dev).
* Source: https://cdn.21st.dev/user_2ra80vx0iKMyQWJ5AESmNfFbCHD/radial-orbital-timeline/code.tsx?v=1
* Evidence: provenance/21ST-JATIN-RADIAL-ORBITAL-LICENSE-EVIDENCE.md
* Stepkit replaces Tailwind/shadcn primitives with native controls and scoped tokens.
*/
export type TimelineStatus = "completed" | "in-progress" | "pending";
export type TimelineIcon = ComponentType<{ size?: number; className?: string; "aria-hidden"?: boolean }>;
export interface TimelineItem {
id: number;
title: string;
date: string;
content: string;
category: string;
icon?: TimelineIcon;
relatedIds: number[];
status: TimelineStatus;
energy: number;
}
export interface OrbitalTimelineProps {
timelineData: TimelineItem[];
title?: string;
paused?: boolean;
className?: string;
}
type OrbitStyle = CSSProperties & Record<`--stepkit-${string}`, string | number>;
function statusLabel(status: TimelineStatus) {
return status === "completed" ? "COMPLETE" : status === "in-progress" ? "IN PROGRESS" : "PENDING";
}
function FallbackIcon({ category }: { category: string }) {
const kind = category.toLowerCase();
const path = kind.includes("design") ? <path d="m4 15 7-11 7 11-7 5-7-5Zm0 0h14" /> : kind.includes("test") ? <path d="M4 4h16v16H4zM8 12h8M8 8h5M8 16h6" /> : kind.includes("release") ? <path d="m5 12 5 5L19 8M12 3v4M12 17v4M3 12h4M17 12h4" /> : <path d="M12 3 4 7v5c0 4.5 3.4 7.5 8 9 4.6-1.5 8-4.5 8-9V7l-8-4Zm0 4v7m0 3h.01" />;
return <svg aria-hidden="true" className="stepkit-orbital-timeline__fallback-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.7">{path}</svg>;
}
export function OrbitalTimeline({ timelineData, title = "Project orbit", paused = false, className = "" }: OrbitalTimelineProps) {
const [expandedItems, setExpandedItems] = useState<Record<number, boolean>>({});
const [rotationAngle, setRotationAngle] = useState(0);
const [autoRotate, setAutoRotate] = useState(true);
const [pulseEffect, setPulseEffect] = useState<Record<number, boolean>>({});
const [activeNodeId, setActiveNodeId] = useState<number | null>(null);
const [reducedMotion, setReducedMotion] = useState(false);
const [focusWithin, setFocusWithin] = useState(false);
const [orbitSize, setOrbitSize] = useState(500);
const containerRef = useRef<HTMLDivElement>(null);
const orbitRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const element = orbitRef.current;
if (!element) return;
const update = () => setOrbitSize(element.getBoundingClientRect().width || 500);
update();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(update);
observer.observe(element);
return () => observer.disconnect();
}, []);
useEffect(() => {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
const media = window.matchMedia("(prefers-reduced-motion: reduce)");
const update = () => setReducedMotion(media.matches);
update();
media.addEventListener?.("change", update);
return () => media.removeEventListener?.("change", update);
}, []);
useEffect(() => {
if (!autoRotate || paused || reducedMotion || focusWithin) return;
const timer = window.setInterval(() => setRotationAngle((current) => (current + 0.3) % 360), 40);
return () => window.clearInterval(timer);
}, [autoRotate, paused, reducedMotion, focusWithin]);
const closeActive = () => {
setExpandedItems({});
setActiveNodeId(null);
setPulseEffect({});
setAutoRotate(true);
};
const handleContainerClick = (event: MouseEvent<HTMLDivElement>) => {
if (event.target === containerRef.current || event.target === orbitRef.current) closeActive();
};
const relatedItems = (id: number) => timelineData.find((item) => item.id === id)?.relatedIds ?? [];
const toggleItem = (id: number) => {
const isOpen = Boolean(expandedItems[id]);
const next = Object.fromEntries(Object.keys(expandedItems).map((key) => [Number(key), false])) as Record<number, boolean>;
next[id] = !isOpen;
setExpandedItems(next);
if (isOpen) {
setActiveNodeId(null);
setPulseEffect({});
setAutoRotate(true);
return;
}
setActiveNodeId(id);
setAutoRotate(false);
setPulseEffect(Object.fromEntries(relatedItems(id).map((relatedId) => [relatedId, true])));
const index = timelineData.findIndex((item) => item.id === id);
if (index >= 0 && timelineData.length > 0) setRotationAngle(270 - (index / timelineData.length) * 360);
};
const calculateNodePosition = (index: number, total: number) => {
const angle = ((index / total) * 360 + rotationAngle) % 360;
const radians = angle * (Math.PI / 180);
const radius = Math.max(70, Math.min(200, orbitSize / 2 - 54));
return {
x: Number((radius * Math.cos(radians)).toFixed(3)),
y: Number((radius * Math.sin(radians)).toFixed(3)),
zIndex: Math.round(100 - 50 * Math.sin(radians)),
opacity: Number((0.4 + 0.6 * ((1 + Math.sin(radians)) / 2)).toFixed(3)),
};
};
return (
<section aria-label={title} className={`stepkit stepkit-orbital-timeline${className ? ` ${className}` : ""}`} data-paused={paused} data-reduced-motion={reducedMotion} onFocusCapture={() => setFocusWithin(true)} onBlurCapture={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setFocusWithin(false); }}>
<header className="stepkit-orbital-timeline__header"><span>Radial orbital timeline</span><h2>{title}</h2><p>Follow related work around a single point of focus.</p></header>
<div className="stepkit-orbital-timeline__stage" ref={containerRef} onClick={handleContainerClick}>
<div className="stepkit-orbital-timeline__orbit" ref={orbitRef}>
<div className="stepkit-orbital-timeline__ring" aria-hidden="true" />
<div className="stepkit-orbital-timeline__center" aria-hidden="true"><span /><i /><i /></div>
{timelineData.map((item, index) => {
const position = calculateNodePosition(index, timelineData.length || 1);
const isExpanded = Boolean(expandedItems[item.id]);
const isRelated = activeNodeId !== null && relatedItems(activeNodeId).includes(item.id);
const ItemIcon = item.icon;
const nodeStyle: OrbitStyle = { transform: `translate(-50%, -50%) translate(${position.x}px, ${position.y}px)`, zIndex: isExpanded ? 200 : position.zIndex, opacity: isExpanded ? 1 : position.opacity };
return (
<div className="stepkit-orbital-timeline__node" key={item.id} style={nodeStyle}>
<span aria-hidden="true" className={`stepkit-orbital-timeline__pulse${pulseEffect[item.id] ? " is-active" : ""}`} style={{ width: `${item.energy * 0.5 + 40}px`, height: `${item.energy * 0.5 + 40}px` }} />
<button aria-expanded={isExpanded} aria-label={`${item.title} timeline item`} className={`stepkit-orbital-timeline__node-button${isRelated ? " is-related" : ""}${isExpanded ? " is-expanded" : ""}`} type="button" onClick={(event) => { event.stopPropagation(); toggleItem(item.id); }}>
{ItemIcon ? <ItemIcon aria-hidden={true} size={16} /> : <FallbackIcon category={item.category} />}
</button>
<span className="stepkit-orbital-timeline__label">{item.title}</span>
{isExpanded && (
<article aria-label={`${item.title} details`} className="stepkit-orbital-timeline__details">
<div className="stepkit-orbital-timeline__details-meta"><span data-status={item.status}>{statusLabel(item.status)}</span><time>{item.date}</time></div>
<h3>{item.title}</h3><p>{item.content}</p>
<div className="stepkit-orbital-timeline__energy"><span><b>Energy level</b><strong>{item.energy}%</strong></span><meter min="0" max="100" value={item.energy}>{item.energy}%</meter></div>
{item.relatedIds.length > 0 && <div className="stepkit-orbital-timeline__related"><span>Connected nodes</span><div>{item.relatedIds.map((relatedId) => { const related = timelineData.find((candidate) => candidate.id === relatedId); return <button key={relatedId} type="button" onClick={(event) => { event.stopPropagation(); toggleItem(relatedId); }}>{related?.title ?? `Node ${relatedId}`} <span aria-hidden="true">→</span></button>; })}</div></div>}
</article>
)}
</div>
);
})}
</div>
</div>
</section>
);
}
export default OrbitalTimeline;
components/OrbitalTimeline.tsxcomponents/adapted-showcase.csscomponents/styles.cssInstall
Use the registry command or download the complete source archive.
npx [email protected] add https://vstepanov.com/stepkit/r/orbital-timeline.jsonIntegration prompt
Install Stepkit Orbital timeline 0.3.0 from the registry and preserve the included MIT notice. npx [email protected] add https://vstepanov.com/stepkit/v0.3.0/r/orbital-timeline.json Install the tested runtime dependencies exactly: npm install [email protected] [email protected] Keep the component source, shared styles, NOTICE.md, and provenance files together. Apply the theme tokens on a wrapper rather than passing theme keys as component props. Usage: ```tsx "use client"; import type { CSSProperties } from "react"; import { OrbitalTimeline } from "./components/stepkit/OrbitalTimeline"; import "./components/stepkit/styles.css"; import "./components/stepkit/adapted-showcase.css"; const themeStyle = {"--stepkit-bg":"#f7f7f8","--stepkit-surface":"#ffffff","--stepkit-surface-muted":"#f0f0f3","--stepkit-fg":"#17181c","--stepkit-muted":"#626572","--stepkit-border":"#e3e4e9","--stepkit-border-strong":"#c8cad3","--stepkit-code":"#f1f1f4","--stepkit-shadow":"0 20px 60px rgba(31, 26, 64, .10)","--stepkit-accent":"#6950df","--stepkit-accent-strong":"#5137c7","--stepkit-accent-soft":"#eeeafd","--stepkit-accent-on-soft":"#5137c7","--stepkit-accent-text":"#ffffff","--stepkit-error":"#b43145","--stepkit-placeholder":"#626572","--stepkit-radius":"16px","--stepkit-space":"16px","--stepkit-control-height":"44px","--stepkit-font-sans":"Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif"} as CSSProperties; const timelineData: import("./components/stepkit/OrbitalTimeline").TimelineItem[] = [{ id: 1, title: "Research", date: "Mar 04", content: "Turn the first signal into a useful direction.", category: "Research", relatedIds: [2], status: "completed", energy: 86 }, { id: 2, title: "Design", date: "Mar 11", content: "Make the idea legible before it becomes large.", category: "Design", relatedIds: [1, 3], status: "in-progress", energy: 68 }, { id: 3, title: "Build", date: "Mar 18", content: "Carry the shape into a working surface.", category: "Build", relatedIds: [2, 4], status: "in-progress", energy: 54 }, { id: 4, title: "Test", date: "Mar 25", content: "Check the details that make the experience hold together.", category: "Testing", relatedIds: [3, 5], status: "pending", energy: 35 }, { id: 5, title: "Release", date: "Apr 01", content: "Give the finished idea a clear path into the world.", category: "Release", relatedIds: [4], status: "pending", energy: 20 }]; export default function Example() { return ( <div className="stepkit-theme" data-stepkit-preset="modern" style={themeStyle}> <OrbitalTimeline timelineData={timelineData} title={"Project orbit"} paused={false} /> </div> ); } ``` Selected bounded settings: {"title":"Project orbit","paused":false}. Selected theme: {"mode":"light","accent":"violet","radius":"soft","density":"comfortable","preset":"modern"}.
Props
| Name | Type | Default | Description |
|---|---|---|---|
timelineData | TimelineItem[] | required | Timeline phases positioned around the orbit. |
title | string | "Project orbit" | Accessible section heading. |
paused | boolean | false | Stops rotation and pulse motion. |
className | string | "" | Additional class name. |
Accessibility & limits
- Every phase is a native button with an expanded details panel.
- Reduced-motion and paused states stop rotation and pulse motion.
- Status, date, content, energy and related phases remain available as text.
Limitations
- Pass a stable timelineData array with unique numeric ids and relatedIds that refer to other phases.
Provenance
Adapted source under the MIT licence.
Upstream: Jatin Yadav Radial Orbital Timeline
Required notices: NOTICE.md, PROVENANCE-stepkit-orbital-timeline.md, NOTICE-21ST-JATIN-YADAV.md