UI Components Hard
Arc Timeline
A timeline displayed along a curved semicircle arc. Events are positioned radially with connecting dots, creating a unique non-linear timeline visualization.
Open in Lab
MCP
css javascript vue svelte
Targets: TS JS HTML React Vue Svelte
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: system-ui, -apple-system, sans-serif;
background: #0a0a0a;
color: #f1f5f9;
min-height: 100vh;
display: grid;
place-items: center;
padding: 2rem;
}
.arc-wrapper {
width: min(700px, 100%);
display: flex;
flex-direction: column;
align-items: center;
gap: 2rem;
}
.arc-title {
font-size: 1.375rem;
font-weight: 700;
color: #f1f5f9;
text-align: center;
}
/* ── Arc container ── */
.arc-container {
position: relative;
width: 600px;
height: 380px;
}
.arc-svg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 320px;
pointer-events: none;
}
.arc-events {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
/* ── Event node ── */
.arc-event {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
opacity: 0;
transform: scale(0.6) translateY(10px);
transition: opacity 0.5s ease, transform 0.5s ease;
}
.arc-event.visible {
opacity: 1;
transform: scale(1) translateY(0);
}
/* ── Dot ── */
.arc-dot {
width: 16px;
height: 16px;
border-radius: 50%;
background: linear-gradient(135deg, #6366f1, #a855f7);
border: 3px solid #0a0a0a;
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.4), 0 0 12px rgba(99, 102, 241, 0.2);
flex-shrink: 0;
z-index: 2;
}
/* ── Label ── */
.arc-label {
margin-top: 0.75rem;
text-align: center;
max-width: 130px;
}
.arc-date {
font-size: 0.7rem;
color: #6366f1;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.arc-label strong {
display: block;
font-size: 0.85rem;
color: #e2e8f0;
margin-top: 0.2rem;
}
.arc-label p {
font-size: 0.75rem;
color: #64748b;
line-height: 1.4;
margin-top: 0.2rem;
}
/* ── Reduced motion ── */
@media (prefers-reduced-motion: reduce) {
.arc-event {
opacity: 1;
transform: none;
transition: none;
}
}
/* ── Responsive ── */
@media (max-width: 640px) {
.arc-container {
width: 100%;
height: 300px;
transform: scale(0.85);
transform-origin: top center;
}
}(function () {
"use strict";
const container = document.getElementById("arc-timeline");
if (!container) return;
const events = container.querySelectorAll(".arc-event");
const arcPath = document.getElementById("arc-path");
const count = events.length;
// Arc parameters
const centerX = 300;
const centerY = 300;
const radius = 250;
const startAngle = Math.PI; // 180 deg (left)
const endAngle = 0; // 0 deg (right)
// Calculate positions along the arc
const points = [];
events.forEach((event, i) => {
const angle = startAngle + (endAngle - startAngle) * (i / (count - 1));
const x = centerX + radius * Math.cos(angle);
const y = centerY + radius * Math.sin(angle);
points.push({ x, y });
// Position the event node (offset to center the dot)
event.style.left = x + "px";
event.style.top = y + "px";
event.style.transform = "translate(-50%, -50%) scale(0.6)";
event.style.transitionDelay = i * 0.12 + "s";
});
// Draw the SVG arc path
if (arcPath && points.length > 1) {
// Create a smooth arc using SVG arc command
const first = points[0];
const last = points[points.length - 1];
// SVG arc: M start A rx ry rotation large-arc-flag sweep-flag end
const d = `M ${first.x} ${first.y} A ${radius} ${radius} 0 0 1 ${last.x} ${last.y}`;
arcPath.setAttribute("d", d);
}
// Trigger entrance animation
function showEvents() {
events.forEach((event) => {
event.classList.add("visible");
// Re-apply translate-centered transform when visible
event.style.transform = "translate(-50%, -50%) scale(1)";
});
}
// Use IntersectionObserver if available
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
showEvents();
observer.unobserve(entry.target);
}
});
},
{ threshold: 0.2 }
);
observer.observe(container);
})();<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Arc Timeline</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="arc-wrapper">
<h2 class="arc-title">Project Timeline</h2>
<div class="arc-container" id="arc-timeline">
<svg class="arc-svg" id="arc-svg" viewBox="0 0 600 320" fill="none" xmlns="http://www.w3.org/2000/svg">
<path id="arc-path" stroke="rgba(148,163,184,0.2)" stroke-width="2" stroke-dasharray="6 4" fill="none" />
</svg>
<div class="arc-events" id="arc-events">
<div class="arc-event" data-index="0">
<div class="arc-dot"></div>
<div class="arc-label">
<span class="arc-date">Jan 2025</span>
<strong>Research</strong>
<p>User interviews & competitive analysis</p>
</div>
</div>
<div class="arc-event" data-index="1">
<div class="arc-dot"></div>
<div class="arc-label">
<span class="arc-date">Mar 2025</span>
<strong>Design</strong>
<p>Wireframes and high-fidelity prototypes</p>
</div>
</div>
<div class="arc-event" data-index="2">
<div class="arc-dot"></div>
<div class="arc-label">
<span class="arc-date">Jun 2025</span>
<strong>Development</strong>
<p>Frontend and backend implementation</p>
</div>
</div>
<div class="arc-event" data-index="3">
<div class="arc-dot"></div>
<div class="arc-label">
<span class="arc-date">Sep 2025</span>
<strong>Testing</strong>
<p>QA, performance, and accessibility audits</p>
</div>
</div>
<div class="arc-event" data-index="4">
<div class="arc-dot"></div>
<div class="arc-label">
<span class="arc-date">Dec 2025</span>
<strong>Launch</strong>
<p>Public release and monitoring</p>
</div>
</div>
</div>
</div>
</div>
<script src="script.js"></script>
</body>
</html>import { useEffect, useRef, useState, useMemo } from "react";
interface ArcTimelineEvent {
date: string;
title: string;
description: string;
}
interface ArcTimelineProps {
events?: ArcTimelineEvent[];
radius?: number;
width?: number;
height?: number;
}
const defaultEvents: ArcTimelineEvent[] = [
{ date: "Jan 2025", title: "Research", description: "User interviews & competitive analysis" },
{ date: "Mar 2025", title: "Design", description: "Wireframes and high-fidelity prototypes" },
{ date: "Jun 2025", title: "Development", description: "Frontend and backend implementation" },
{ date: "Sep 2025", title: "Testing", description: "QA, performance, and accessibility audits" },
{ date: "Dec 2025", title: "Launch", description: "Public release and monitoring" },
];
export default function ArcTimeline({
events = defaultEvents,
radius = 250,
width = 600,
height = 380,
}: ArcTimelineProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
const centerX = width / 2;
const centerY = height - 80;
const startAngle = Math.PI;
const endAngle = 0;
const count = events.length;
const positions = useMemo(() => {
return events.map((_, i) => {
const angle = startAngle + (endAngle - startAngle) * (i / (count - 1));
return {
x: centerX + radius * Math.cos(angle),
y: centerY + radius * Math.sin(angle),
};
});
}, [events, radius, centerX, centerY, count]);
const arcPathD = useMemo(() => {
if (positions.length < 2) return "";
const first = positions[0];
const last = positions[positions.length - 1];
return `M ${first.x} ${first.y} A ${radius} ${radius} 0 0 1 ${last.x} ${last.y}`;
}, [positions, radius]);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.unobserve(el);
}
},
{ threshold: 0.2 }
);
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<div
style={{
minHeight: "100vh",
background: "#0a0a0a",
display: "grid",
placeItems: "center",
padding: "2rem",
fontFamily: "system-ui, -apple-system, sans-serif",
color: "#f1f5f9",
}}
>
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "2rem" }}>
<h2 style={{ fontSize: "1.375rem", fontWeight: 700, textAlign: "center" }}>
Project Timeline
</h2>
<div ref={containerRef} style={{ position: "relative", width, height }}>
{/* SVG arc path */}
<svg
viewBox={`0 0 ${width} ${height - 60}`}
fill="none"
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: height - 60,
pointerEvents: "none",
}}
>
<path
d={arcPathD}
stroke="rgba(148,163,184,0.2)"
strokeWidth={2}
strokeDasharray="6 4"
fill="none"
/>
</svg>
{/* Event nodes */}
{events.map((event, i) => (
<div
key={i}
style={{
position: "absolute",
left: positions[i].x,
top: positions[i].y,
transform: visible
? "translate(-50%, -50%) scale(1)"
: "translate(-50%, -50%) scale(0.6)",
opacity: visible ? 1 : 0,
transition: `opacity 0.5s ease ${i * 0.12}s, transform 0.5s ease ${i * 0.12}s`,
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
{/* Dot */}
<div
style={{
width: 16,
height: 16,
borderRadius: "50%",
background: "linear-gradient(135deg, #6366f1, #a855f7)",
border: "3px solid #0a0a0a",
boxShadow: "0 0 0 2px rgba(99,102,241,0.4), 0 0 12px rgba(99,102,241,0.2)",
flexShrink: 0,
zIndex: 2,
}}
/>
{/* Label */}
<div style={{ marginTop: "0.75rem", textAlign: "center", maxWidth: 130 }}>
<span
style={{
fontSize: "0.7rem",
color: "#6366f1",
fontWeight: 600,
letterSpacing: "0.04em",
textTransform: "uppercase",
}}
>
{event.date}
</span>
<strong
style={{
display: "block",
fontSize: "0.85rem",
color: "#e2e8f0",
marginTop: "0.2rem",
}}
>
{event.title}
</strong>
<p
style={{
fontSize: "0.75rem",
color: "#64748b",
lineHeight: 1.4,
marginTop: "0.2rem",
}}
>
{event.description}
</p>
</div>
</div>
))}
</div>
</div>
</div>
);
}<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
const props = withDefaults(defineProps<{
events?: { date: string; title: string; description: string }[]
radius?: number
width?: number
height?: number
}>(), {
events: () => [
{ date: "Jan 2025", title: "Research", description: "User interviews & competitive analysis" },
{ date: "Mar 2025", title: "Design", description: "Wireframes and high-fidelity prototypes" },
{ date: "Jun 2025", title: "Development", description: "Frontend and backend implementation" },
{ date: "Sep 2025", title: "Testing", description: "QA, performance, and accessibility audits" },
{ date: "Dec 2025", title: "Launch", description: "Public release and monitoring" },
],
radius: 250,
width: 600,
height: 380,
})
const containerEl = ref(null)
const visible = ref(false)
const centerX = computed(() => props.width / 2)
const centerY = computed(() => props.height - 80)
const positions = computed(() =>
props.events.map((_, i) => {
const angle = Math.PI + (0 - Math.PI) * (i / (props.events.length - 1))
return { x: centerX.value + props.radius * Math.cos(angle), y: centerY.value + props.radius * Math.sin(angle) }
})
)
const arcPathD = computed(() => {
if (positions.value.length < 2) return ""
const f = positions.value[0]
const l = positions.value[positions.value.length - 1]
return `M ${f.x} ${f.y} A ${props.radius} ${props.radius} 0 0 1 ${l.x} ${l.y}`
})
let observer
onMounted(() => {
observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) { visible.value = true; observer.unobserve(containerEl.value) }
}, { threshold: 0.2 })
observer.observe(containerEl.value)
})
onUnmounted(() => observer?.disconnect())
</script>
<template>
<div style="min-height:100vh;background:#0a0a0a;display:grid;place-items:center;padding:2rem;font-family:system-ui,-apple-system,sans-serif;color:#f1f5f9">
<div style="display:flex;flex-direction:column;align-items:center;gap:2rem">
<h2 style="font-size:1.375rem;font-weight:700;text-align:center">Project Timeline</h2>
<div ref="containerEl" :style="{ position:'relative', width: props.width+'px', height: props.height+'px' }">
<svg :viewBox="`0 0 ${props.width} ${props.height - 60}`" fill="none" :style="{ position:'absolute', top:0, left:0, width:'100%', height: (props.height-60)+'px', pointerEvents:'none' }">
<path :d="arcPathD" stroke="rgba(148,163,184,0.2)" stroke-width="2" stroke-dasharray="6 4" fill="none"/>
</svg>
<div v-for="(event, i) in props.events" :key="i" :style="{
position:'absolute', left: positions[i].x+'px', top: positions[i].y+'px',
transform: `translate(-50%,-50%) ${visible ? 'scale(1)' : 'scale(0.6)'}`,
opacity: visible ? 1 : 0,
transition: `opacity 0.5s ease ${i * 0.12}s, transform 0.5s ease ${i * 0.12}s`,
display:'flex', flexDirection:'column', alignItems:'center'
}">
<div style="width:16px;height:16px;border-radius:50%;background:linear-gradient(135deg,#6366f1,#a855f7);border:3px solid #0a0a0a;box-shadow:0 0 0 2px rgba(99,102,241,0.4),0 0 12px rgba(99,102,241,0.2);flex-shrink:0;z-index:2"></div>
<div style="margin-top:0.75rem;text-align:center;max-width:130px">
<span style="font-size:0.7rem;color:#6366f1;font-weight:600;letter-spacing:0.04em;text-transform:uppercase">{{ event.date }}</span>
<strong style="display:block;font-size:0.85rem;color:#e2e8f0;margin-top:0.2rem">{{ event.title }}</strong>
<p style="font-size:0.75rem;color:#64748b;line-height:1.4;margin-top:0.2rem">{{ event.description }}</p>
</div>
</div>
</div>
</div>
</div>
</template><script>
import { onMount } from "svelte";
const defaultEvents = [
{ date: "Jan 2025", title: "Research", description: "User interviews & competitive analysis" },
{ date: "Mar 2025", title: "Design", description: "Wireframes and high-fidelity prototypes" },
{ date: "Jun 2025", title: "Development", description: "Frontend and backend implementation" },
{ date: "Sep 2025", title: "Testing", description: "QA, performance, and accessibility audits" },
{ date: "Dec 2025", title: "Launch", description: "Public release and monitoring" },
];
export let events = defaultEvents;
export let radius = 250;
export let width = 600;
export let height = 380;
let containerEl;
let visible = false;
$: centerX = width / 2;
$: centerY = height - 80;
$: positions = events.map((_, i) => {
const angle = Math.PI + (0 - Math.PI) * (i / (events.length - 1));
return { x: centerX + radius * Math.cos(angle), y: centerY + radius * Math.sin(angle) };
});
$: arcPathD =
positions.length >= 2
? `M ${positions[0].x} ${positions[0].y} A ${radius} ${radius} 0 0 1 ${positions[positions.length - 1].x} ${positions[positions.length - 1].y}`
: "";
onMount(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
visible = true;
observer.unobserve(containerEl);
}
},
{ threshold: 0.2 }
);
observer.observe(containerEl);
return () => observer.disconnect();
});
</script>
<div style="min-height:100vh;background:#0a0a0a;display:grid;place-items:center;padding:2rem;font-family:system-ui,-apple-system,sans-serif;color:#f1f5f9">
<div style="display:flex;flex-direction:column;align-items:center;gap:2rem">
<h2 style="font-size:1.375rem;font-weight:700;text-align:center">Project Timeline</h2>
<div bind:this={containerEl} style="position:relative;width:{width}px;height:{height}px">
<svg viewBox="0 0 {width} {height - 60}" fill="none" style="position:absolute;top:0;left:0;width:100%;height:{height - 60}px;pointer-events:none">
<path d={arcPathD} stroke="rgba(148,163,184,0.2)" stroke-width="2" stroke-dasharray="6 4" fill="none"/>
</svg>
{#each events as event, i}
<div style="position:absolute;left:{positions[i].x}px;top:{positions[i].y}px;transform:translate(-50%,-50%) {visible ? 'scale(1)' : 'scale(0.6)'};opacity:{visible ? 1 : 0};transition:opacity 0.5s ease {i * 0.12}s, transform 0.5s ease {i * 0.12}s;display:flex;flex-direction:column;align-items:center">
<div style="width:16px;height:16px;border-radius:50%;background:linear-gradient(135deg,#6366f1,#a855f7);border:3px solid #0a0a0a;box-shadow:0 0 0 2px rgba(99,102,241,0.4),0 0 12px rgba(99,102,241,0.2);flex-shrink:0;z-index:2"></div>
<div style="margin-top:0.75rem;text-align:center;max-width:130px">
<span style="font-size:0.7rem;color:#6366f1;font-weight:600;letter-spacing:0.04em;text-transform:uppercase">{event.date}</span>
<strong style="display:block;font-size:0.85rem;color:#e2e8f0;margin-top:0.2rem">{event.title}</strong>
<p style="font-size:0.75rem;color:#64748b;line-height:1.4;margin-top:0.2rem">{event.description}</p>
</div>
</div>
{/each}
</div>
</div>
</div>Arc Timeline
A curved semicircle timeline that positions events along an arc rather than in a straight line. Each event node sits at a calculated angle along the arc, with labels extending outward.
How it works
- A container element defines the arc dimensions.
- JavaScript calculates each item’s position using
Math.cosandMath.sinbased on its index, distributing events evenly along a 180-degree arc. - Items are absolutely positioned using
top/lefttransforms from arc center. - A dotted SVG arc path connects the event nodes visually.
Features
- Events distributed evenly along a semicircular arc
- SVG arc path connecting all nodes
- Animated entrance with staggered delays
- Responsive sizing