Constellation
import { useId, useRef, useState } from 'react';
const SKY = [
{
label: 'Orion',
body: 'The hunter, with three stars in a row for his belt.',
stars: [[62, 16], [152, 22], [96, 54], [110, 51], [124, 48], [158, 90]],
lines: [[0, 1], [0, 2], [1, 4], [2, 3], [3, 4], [4, 5], [2, 5]],
},
{
label: 'Lyra',
body: 'A small lyre hanging from Vega, one of the brightest stars.',
stars: [[112, 12], [96, 42], [128, 38], [88, 82], [136, 78], [168, 26]],
lines: [[0, 1], [0, 2], [1, 2], [1, 3], [2, 4], [3, 4]],
},
{
label: 'Cassiopeia',
body: 'A queen on her throne, drawn as a crooked W.',
stars: [[28, 34], [72, 66], [112, 36], [154, 70], [196, 30], [118, 92]],
lines: [[0, 1], [1, 2], [2, 3], [3, 4]],
},
];
const DUST = [[14, 80], [40, 12], [84, 30], [140, 8], [182, 58], [206, 94], [70, 98], [190, 14], [24, 58], [128, 70]];
const css = `
@keyframes tabs-constellation-twinkle { 50% { opacity: 0.35; } }
.tabs-constellation { width: 250px; }
.tabs-constellation__list { display: flex; justify-content: center; gap: 4px; margin-bottom: 10px; }
.tabs-constellation__tab { padding: 5px 10px; border: 0; border-radius: 999px; background: none; color: light-dark(#5f626a, #a7abb2); font: inherit; font-size: 12px; font-weight: 600; cursor: pointer; transition: color 0.2s, box-shadow 0.2s; }
.tabs-constellation__tab:hover { color: light-dark(#42454c, #e5e6e9); }
.tabs-constellation__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); box-shadow: inset 0 0 0 1px light-dark(#6d7179, #7d8087); }
.tabs-constellation__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; }
.tabs-constellation__sky { position: relative; height: 104px; overflow: hidden; border-radius: 12px; background: radial-gradient(ellipse at 50% 120%, light-dark(#ebedf0, #232529), light-dark(#fbfcfd, #121417) 70%); }
.tabs-constellation__sky svg { position: absolute; left: 15px; top: 0; width: 220px; height: 104px; overflow: visible; }
.tabs-constellation__dust { fill: light-dark(#cbced3, #393c41); }
.tabs-constellation__line { fill: none; stroke: color-mix(in oklab, light-dark(#42454c, #e5e6e9) 60%, transparent); stroke-width: 1; stroke-dasharray: 100; }
.tabs-constellation[data-armed] .tabs-constellation__line { animation: tabs-constellation-draw 0.6s ease-out 0.55s backwards; }
@keyframes tabs-constellation-draw { from { stroke-dashoffset: 100; } }
.tabs-constellation__star { position: absolute; left: 15px; top: 0; width: 7px; height: 7px; margin: -3.5px; border-radius: 50%; background: light-dark(#373a40, #f1f2f3); box-shadow: 0 0 8px light-dark(#42454c, #e5e6e9); transform: translate(var(--x), var(--y)); transition: transform 0.7s cubic-bezier(0.5, 0, 0.2, 1); animation: tabs-constellation-twinkle 2.4s ease-in-out infinite; }
.tabs-constellation__panel { min-height: calc(3.1em + 10px); padding: 10px 4px 0; color: light-dark(#50535b, #c6c8cd); font-size: 12.5px; line-height: 1.55; text-align: center; }
@media (prefers-reduced-motion: reduce) {
.tabs-constellation__star { transition: none; animation: none; }
.tabs-constellation__line { animation: none !important; }
}
`;
export function ConstellationTabs() {
const id = useId();
const [sel, setSel] = useState(0);
const [armed, setArmed] = useState(false);
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const select = (i: number) => {
if (i === sel) return;
setArmed(true);
setSel(i);
};
const onKeyDown = (e: React.KeyboardEvent) => {
const n = SKY.length;
const next = { ArrowRight: (sel + 1) % n, ArrowLeft: (sel + n - 1) % n, Home: 0, End: n - 1 }[e.key];
if (next === undefined) return;
e.preventDefault();
select(next);
tabs.current[next]?.focus();
};
const c = SKY[sel];
return (
<>
<style>
{css}
</style>
<div className="tabs-constellation" data-armed={armed ? '' : undefined}>
<div className="tabs-constellation__list" role="tablist" aria-label="Constellations" onKeyDown={onKeyDown}>
{SKY.map((s, i) => (
<button
key={s.label}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-constellation__tab"
type="button"
role="tab"
id={`${id}-tab-${i}`}
aria-selected={sel === i}
aria-controls={`${id}-panel-${i}`}
tabIndex={sel === i ? 0 : -1}
onClick={() => select(i)}
>
{s.label}
</button>
))}
</div>
<div className="tabs-constellation__sky" aria-hidden="true">
<svg viewBox="0 0 220 104">
{DUST.map(([x, y]) => (
<circle key={`${x}-${y}`} className="tabs-constellation__dust" cx={x} cy={y} r="1" />
))}
{/* Keyed by the constellation, so the lines remount and draw in fresh each time. */}
<g key={sel}>
{c.lines.map(([a, b]) => (
<path key={`${a}-${b}`} className="tabs-constellation__line" pathLength={100} d={`M${c.stars[a][0]} ${c.stars[a][1]}L${c.stars[b][0]} ${c.stars[b][1]}`} />
))}
</g>
</svg>
{c.stars.map(([x, y], k) => (
<span key={k} className="tabs-constellation__star" style={{ '--x': `${x}px`, '--y': `${y}px`, animationDelay: `${k * -0.4}s` } as React.CSSProperties} />
))}
</div>
{SKY.map((s, i) => (
<div key={s.label} className="tabs-constellation__panel" role="tabpanel" id={`${id}-panel-${i}`} aria-labelledby={`${id}-tab-${i}`} hidden={sel !== i}>
{s.body}
</div>
))}
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initTabsConstellation(el) on the mounted element instead.
<div class="tabs-constellation">
<div class="tabs-constellation__list" role="tablist" aria-label="Constellations">
<button class="tabs-constellation__tab" type="button" role="tab" id="tabs-constellation-tab-0" aria-selected="true" aria-controls="tabs-constellation-panel-0" tabindex="0">Orion</button>
<button class="tabs-constellation__tab" type="button" role="tab" id="tabs-constellation-tab-1" aria-selected="false" aria-controls="tabs-constellation-panel-1" tabindex="-1">Lyra</button>
<button class="tabs-constellation__tab" type="button" role="tab" id="tabs-constellation-tab-2" aria-selected="false" aria-controls="tabs-constellation-panel-2" tabindex="-1">Cassiopeia</button>
</div>
<div class="tabs-constellation__sky" aria-hidden="true">
<svg viewBox="0 0 220 104">
<circle class="tabs-constellation__dust" cx="14" cy="80" r="1"></circle>
<circle class="tabs-constellation__dust" cx="40" cy="12" r="1"></circle>
<circle class="tabs-constellation__dust" cx="84" cy="30" r="1"></circle>
<circle class="tabs-constellation__dust" cx="140" cy="8" r="1"></circle>
<circle class="tabs-constellation__dust" cx="182" cy="58" r="1"></circle>
<circle class="tabs-constellation__dust" cx="206" cy="94" r="1"></circle>
<circle class="tabs-constellation__dust" cx="70" cy="98" r="1"></circle>
<circle class="tabs-constellation__dust" cx="190" cy="14" r="1"></circle>
<circle class="tabs-constellation__dust" cx="24" cy="58" r="1"></circle>
<circle class="tabs-constellation__dust" cx="128" cy="70" r="1"></circle>
<g>
<path class="tabs-constellation__line" pathLength="100" d="M62 16L152 22"></path>
<path class="tabs-constellation__line" pathLength="100" d="M62 16L96 54"></path>
<path class="tabs-constellation__line" pathLength="100" d="M152 22L124 48"></path>
<path class="tabs-constellation__line" pathLength="100" d="M96 54L110 51"></path>
<path class="tabs-constellation__line" pathLength="100" d="M110 51L124 48"></path>
<path class="tabs-constellation__line" pathLength="100" d="M124 48L158 90"></path>
<path class="tabs-constellation__line" pathLength="100" d="M96 54L158 90"></path>
</g>
</svg>
<span class="tabs-constellation__star" style="--x:62px;--y:16px;animation-delay:0s"></span>
<span class="tabs-constellation__star" style="--x:152px;--y:22px;animation-delay:-0.4s"></span>
<span class="tabs-constellation__star" style="--x:96px;--y:54px;animation-delay:-0.8s"></span>
<span class="tabs-constellation__star" style="--x:110px;--y:51px;animation-delay:-1.2000000000000002s"></span>
<span class="tabs-constellation__star" style="--x:124px;--y:48px;animation-delay:-1.6s"></span>
<span class="tabs-constellation__star" style="--x:158px;--y:90px;animation-delay:-2s"></span>
</div>
<div class="tabs-constellation__panel" role="tabpanel" id="tabs-constellation-panel-0" aria-labelledby="tabs-constellation-tab-0">The hunter, with three stars in a row for his belt.</div>
<div class="tabs-constellation__panel" role="tabpanel" id="tabs-constellation-panel-1" aria-labelledby="tabs-constellation-tab-1" hidden="">A small lyre hanging from Vega, one of the brightest stars.</div>
<div class="tabs-constellation__panel" role="tabpanel" id="tabs-constellation-panel-2" aria-labelledby="tabs-constellation-tab-2" hidden="">A queen on her throne, drawn as a crooked W.</div>
</div>@keyframes tabs-constellation-twinkle { 50% { opacity: 0.35; } }
.tabs-constellation { width: 250px; }
.tabs-constellation__list { display: flex; justify-content: center; gap: 4px; margin-bottom: 10px; }
.tabs-constellation__tab { padding: 5px 10px; border: 0; border-radius: 999px; background: none; color: light-dark(#5f626a, #a7abb2); font: inherit; font-size: 12px; font-weight: 600; cursor: pointer; transition: color 0.2s, box-shadow 0.2s; }
.tabs-constellation__tab:hover { color: light-dark(#42454c, #e5e6e9); }
.tabs-constellation__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); box-shadow: inset 0 0 0 1px light-dark(#6d7179, #7d8087); }
.tabs-constellation__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; }
.tabs-constellation__sky { position: relative; height: 104px; overflow: hidden; border-radius: 12px; background: radial-gradient(ellipse at 50% 120%, light-dark(#ebedf0, #232529), light-dark(#fbfcfd, #121417) 70%); }
.tabs-constellation__sky svg { position: absolute; left: 15px; top: 0; width: 220px; height: 104px; overflow: visible; }
.tabs-constellation__dust { fill: light-dark(#cbced3, #393c41); }
.tabs-constellation__line { fill: none; stroke: color-mix(in oklab, light-dark(#42454c, #e5e6e9) 60%, transparent); stroke-width: 1; stroke-dasharray: 100; }
.tabs-constellation[data-armed] .tabs-constellation__line { animation: tabs-constellation-draw 0.6s ease-out 0.55s backwards; }
@keyframes tabs-constellation-draw { from { stroke-dashoffset: 100; } }
.tabs-constellation__star { position: absolute; left: 15px; top: 0; width: 7px; height: 7px; margin: -3.5px; border-radius: 50%; background: light-dark(#373a40, #f1f2f3); box-shadow: 0 0 8px light-dark(#42454c, #e5e6e9); transform: translate(var(--x), var(--y)); transition: transform 0.7s cubic-bezier(0.5, 0, 0.2, 1); animation: tabs-constellation-twinkle 2.4s ease-in-out infinite; }
.tabs-constellation__panel { min-height: calc(3.1em + 10px); padding: 10px 4px 0; color: light-dark(#50535b, #c6c8cd); font-size: 12.5px; line-height: 1.55; text-align: center; }
@media (prefers-reduced-motion: reduce) {
.tabs-constellation__star { transition: none; animation: none; }
.tabs-constellation__line { animation: none !important; }
}function initTabsConstellation(root) {
var list = root.querySelector('[role="tablist"]');
var tabs = Array.prototype.slice.call(list.querySelectorAll('[role="tab"]'));
var panels = root.querySelectorAll('[role="tabpanel"]');
var sel = tabs.findIndex(function (tab) { return tab.getAttribute('aria-selected') === 'true'; });
var SKY = [
{ stars: [[62, 16], [152, 22], [96, 54], [110, 51], [124, 48], [158, 90]], lines: [[0, 1], [0, 2], [1, 4], [2, 3], [3, 4], [4, 5], [2, 5]] },
{ stars: [[112, 12], [96, 42], [128, 38], [88, 82], [136, 78], [168, 26]], lines: [[0, 1], [0, 2], [1, 2], [1, 3], [2, 4], [3, 4]] },
{ stars: [[28, 34], [72, 66], [112, 36], [154, 70], [196, 30], [118, 92]], lines: [[0, 1], [1, 2], [2, 3], [3, 4]] },
];
var stars = root.querySelectorAll('.tabs-constellation__star');
var group = root.querySelector('.tabs-constellation__sky g');
function select(i) {
if (i === sel) return;
var prev = sel;
sel = i;
tabs.forEach(function (tab, k) {
tab.setAttribute('aria-selected', String(k === i));
tab.tabIndex = k === i ? 0 : -1;
});
panels.forEach(function (panel, k) { panel.hidden = k !== i; });
// Armed on the first change, so nothing animates on load.
root.setAttribute('data-armed', '');
// The stars glide to their new places; the lines are new elements, so they draw in fresh.
var c = SKY[i];
stars.forEach(function (star, k) {
star.style.setProperty('--x', c.stars[k][0] + 'px');
star.style.setProperty('--y', c.stars[k][1] + 'px');
});
group.replaceChildren();
c.lines.forEach(function (l) {
var a = c.stars[l[0]];
var b = c.stars[l[1]];
var path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('class', 'tabs-constellation__line');
path.setAttribute('pathLength', '100');
path.setAttribute('d', 'M' + a[0] + ' ' + a[1] + 'L' + b[0] + ' ' + b[1]);
group.appendChild(path);
});
}
function onClick(e) {
var tab = e.target.closest('[role="tab"]');
if (tab) select(tabs.indexOf(tab));
}
function onKeyDown(e) {
var n = tabs.length;
var next = { ArrowRight: (sel + 1) % n, ArrowLeft: (sel + n - 1) % n, Home: 0, End: n - 1 }[e.key];
if (next === undefined) return;
e.preventDefault();
select(next);
tabs[next].focus();
}
list.addEventListener('click', onClick);
list.addEventListener('keydown', onKeyDown);
return function () {
list.removeEventListener('click', onClick);
list.removeEventListener('keydown', onKeyDown);
};
}
document.querySelectorAll('.tabs-constellation').forEach(initTabsConstellation);Goal: Create a "Constellation" tab set.
Visual behavior: Constellations as tabs: the stars glide into the next shape, the old lines vanish and the new ones draw themselves in once the stars arrive.
Technique:
- The ARIA tabs pattern: a role="tablist" with an aria-label, buttons with role="tab", aria-selected and aria-controls, and role="tabpanel" panels labelled by their tab. Only the selected tab is in the Tab order (roving tabindex); the arrow keys, Home and End move between tabs and select as they go.
- Six stars are positioned from --x and --y custom properties and transition to each constellation's layout; they also twinkle on a loop.
- The lines are new SVG paths each time (pathLength 100), drawn in with a dash-offset animation after a short delay, so they wait for the stars.
- A data-armed attribute is set on the first change, so nothing plays on page load.
Accessibility: Real ARIA tabs: arrow keys, Home and End move between tabs, only the selected tab is in the Tab order, and each panel is labelled by its tab. Decoration is aria-hidden. Under prefers-reduced-motion the stars and lines change in place without moving, drawing or twinkling.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Constellation" tab set.
Visual behavior: Constellations as tabs: the stars glide into the next shape, the old lines vanish and the new ones draw themselves in once the stars arrive.
Technique:
- The ARIA tabs pattern: a role="tablist" with an aria-label, buttons with role="tab", aria-selected and aria-controls, and role="tabpanel" panels labelled by their tab. Only the selected tab is in the Tab order (roving tabindex); the arrow keys, Home and End move between tabs and select as they go.
- Six stars are positioned from --x and --y custom properties and transition to each constellation's layout; they also twinkle on a loop.
- The lines are new SVG paths each time (pathLength 100), drawn in with a dash-offset animation after a short delay, so they wait for the stars.
- A data-armed attribute is set on the first change, so nothing plays on page load.
Accessibility: Real ARIA tabs: arrow keys, Home and End move between tabs, only the selected tab is in the Tab order, and each panel is labelled by its tab. Decoration is aria-hidden. Under prefers-reduced-motion the stars and lines change in place without moving, drawing or twinkling.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
Constellations as tabs: the stars glide into the next shape, the old lines vanish and the new ones draw themselves in once the stars arrive.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, state change, draw
- Techniques
- css variables, stroke dashoffset, svg
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
Real ARIA tabs: arrow keys, Home and End move between tabs, only the selected tab is in the Tab order, and each panel is labelled by its tab. Decoration is aria-hidden. Under prefers-reduced-motion the stars and lines change in place without moving, drawing or twinkling.