Constellation
import { useState } from 'react';
const css = `
@keyframes field-constellation-star { from { transform: scale(0); opacity: 0; } 60% { transform: scale(1.8); opacity: 1; } }
@keyframes field-constellation-twinkle { 50% { opacity: 0.55; } }
@keyframes field-constellation-line { to { stroke-dashoffset: 0; } }
.field-constellation { width: 256px; }
.field-constellation__sky {
position: relative;
height: 112px;
margin-bottom: 8px;
border-radius: 12px;
background: radial-gradient(ellipse at 50% 120%, light-dark(#ebedf0, #232529), light-dark(#fbfcfd, #121417) 70%);
box-shadow: inset 0 0 0 1px light-dark(#ebedf0, #232529);
overflow: hidden;
}
.field-constellation__sky svg { position: absolute; inset: 0; width: 256px; height: 112px; }
.field-constellation__line { fill: none; stroke: light-dark(#5f626a, #a7abb2); stroke-width: 0.8; stroke-dasharray: 1; stroke-dashoffset: 1; animation: field-constellation-line 0.5s ease-out 0.1s forwards; }
.field-constellation__star { fill: light-dark(#373a40, #f1f2f3); transform-box: fill-box; transform-origin: center; animation: field-constellation-star 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) both, field-constellation-twinkle 2.6s ease-in-out 0.5s infinite; }
.field-constellation__fig { transition: transform 0.4s cubic-bezier(0.2, 0.8, 0.3, 1); }
.field-constellation__dust { fill: light-dark(#cbced3, #393c41); }
.field-constellation__name { position: absolute; left: 10px; bottom: 7px; color: light-dark(#5f626a, #a7abb2); font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; }
.field-constellation__label { display: block; margin-bottom: 6px; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-constellation__input {
width: 100%;
height: 38px;
padding: 0 12px;
border: 0;
outline: 0;
border-radius: 9px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
color: light-dark(#373a40, #f1f2f3);
font-family: inherit; font-size: 14px;
transition: box-shadow 0.2s;
}
.field-constellation__input:focus { box-shadow: inset 0 0 0 1px light-dark(#5f626a, #a7abb2); }
@media (prefers-reduced-motion: reduce) { .field-constellation__line, .field-constellation__star { animation: none; stroke-dashoffset: 0; } }
`;
const W = 256;
const H = 112;
const STEP = 9.4;
const hash = (n: number) => {
const x = Math.sin(n * 127.1 + 311.7) * 43758.5453;
return x - Math.floor(x);
};
// Background dust, fixed.
const r2 = (n: number) => Math.round(n * 100) / 100;
const DUST = Array.from({ length: 26 }, (_, i) => [r2(hash(i) * W), r2(hash(i + 40) * H), r2(0.4 + hash(i + 80) * 0.6)]);
export function ConstellationInput() {
const [value, setValue] = useState('wish');
// Each letter becomes a star: across by its position, up and down by the letter itself. Spaces break the line.
const stars = [...value].map((c, i) => ({
c,
x: r2(18 + i * STEP + (hash(i + 7) - 0.5) * 6),
y: r2(16 + hash(c.charCodeAt(0) * 3.1 + i * 0.7) * (H - 40)),
r: r2(1.3 + hash(c.charCodeAt(0)) * 1.6),
}));
// The whole figure stays centred in the sky, gliding over as it grows.
const shift = (W - 36 - Math.max(0, value.length - 1) * STEP) / 2;
return (
<>
<style>
{css}
</style>
<div className="field-constellation">
<div className="field-constellation__sky" aria-hidden="true">
<svg viewBox={`0 0 ${W} ${H}`}>
{DUST.map(([x, y, r], i) => (
<circle key={i} className="field-constellation__dust" cx={x} cy={y} r={r} />
))}
<g className="field-constellation__fig" style={{ transform: `translateX(${shift}px)` }}>
{stars.map((s, i) => {
const p = stars[i - 1];
if (!p || s.c === ' ' || p.c === ' ') return null;
return <line key={`l${i}${s.c}${p.c}`} className="field-constellation__line" x1={p.x} y1={p.y} x2={s.x} y2={s.y} pathLength={1} />;
})}
{stars.map((s, i) => (s.c === ' ' ? null : <circle key={`s${i}${s.c}`} className="field-constellation__star" cx={s.x} cy={s.y} r={s.r} style={{ animationDelay: `0s, ${(0.5 + hash(i) * 2).toFixed(2)}s` }} />))}
</g>
</svg>
<span className="field-constellation__name">{value.replace(/\s/g, '').length} stars</span>
</div>
<label className="field-constellation__label" htmlFor="field-constellation-input">
Make a wish
</label>
<input
id="field-constellation-input"
className="field-constellation__input"
value={value}
maxLength={24}
autoComplete="off"
spellCheck={false}
onChange={(e) => setValue(e.target.value)}
/>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initFieldConstellation(el) on the mounted element instead.
<div class="field-constellation">
<div class="field-constellation__sky" aria-hidden="true">
<svg viewBox="0 0 256 112">
<circle class="field-constellation__dust" cx="209.74" cy="14.51" r="0.6"></circle>
<circle class="field-constellation__dust" cx="26.8" cy="63.12" r="0.95"></circle>
<circle class="field-constellation__dust" cx="204.83" cy="32.61" r="0.94"></circle>
<circle class="field-constellation__dust" cx="161.2" cy="62.17" r="1"></circle>
<circle class="field-constellation__dust" cx="199.3" cy="53.1" r="0.41"></circle>
<circle class="field-constellation__dust" cx="143.97" cy="81.66" r="0.43"></circle>
<circle class="field-constellation__dust" cx="251.38" cy="71.57" r="0.85"></circle>
<circle class="field-constellation__dust" cx="234.97" cy="50.69" r="0.96"></circle>
<circle class="field-constellation__dust" cx="210.51" cy="24.64" r="0.81"></circle>
<circle class="field-constellation__dust" cx="242.88" cy="67.44" r="0.41"></circle>
<circle class="field-constellation__dust" cx="191.92" cy="6.5" r="0.85"></circle>
<circle class="field-constellation__dust" cx="166.28" cy="52.03" r="0.7"></circle>
<circle class="field-constellation__dust" cx="52.66" cy="1.95" r="0.56"></circle>
<circle class="field-constellation__dust" cx="239.56" cy="35.81" r="0.85"></circle>
<circle class="field-constellation__dust" cx="30.89" cy="107.81" r="0.98"></circle>
<circle class="field-constellation__dust" cx="212.57" cy="22.1" r="0.77"></circle>
<circle class="field-constellation__dust" cx="44.37" cy="7.87" r="0.51"></circle>
<circle class="field-constellation__dust" cx="17.23" cy="49.74" r="0.89"></circle>
<circle class="field-constellation__dust" cx="241.98" cy="47.77" r="0.72"></circle>
<circle class="field-constellation__dust" cx="50.97" cy="98.42" r="0.54"></circle>
<circle class="field-constellation__dust" cx="186.41" cy="59.29" r="0.94"></circle>
<circle class="field-constellation__dust" cx="27.35" cy="80.79" r="0.48"></circle>
<circle class="field-constellation__dust" cx="100.73" cy="17.86" r="0.84"></circle>
<circle class="field-constellation__dust" cx="217.82" cy="92.25" r="0.45"></circle>
<circle class="field-constellation__dust" cx="58.29" cy="72.4" r="0.76"></circle>
<circle class="field-constellation__dust" cx="136.39" cy="78.65" r="0.64"></circle>
<g class="field-constellation__fig" style="transform:translateX(95.9px)">
<line class="field-constellation__line" x1="20.51" y1="26.93" x2="29.33" y2="55.86" pathLength="1"></line>
<line class="field-constellation__line" x1="29.33" y1="55.86" x2="39.49" y2="69.6" pathLength="1"></line>
<line class="field-constellation__line" x1="39.49" y1="69.6" x2="47.7" y2="63.5" pathLength="1"></line>
<circle class="field-constellation__star" cx="20.51" cy="26.93" r="1.91" style="animation-delay:0s, 2.14s"></circle>
<circle class="field-constellation__star" cx="29.33" cy="55.86" r="1.94" style="animation-delay:0s, 0.71s"></circle>
<circle class="field-constellation__star" cx="39.49" cy="69.6" r="2.16" style="animation-delay:0s, 2.10s"></circle>
<circle class="field-constellation__star" cx="47.7" cy="63.5" r="2.27" style="animation-delay:0s, 1.76s"></circle>
</g>
</svg>
<span class="field-constellation__name">4 stars</span>
</div>
<label class="field-constellation__label" for="field-constellation-input">Make a wish</label>
<input id="field-constellation-input" class="field-constellation__input" maxLength="24" autoComplete="off" spellCheck="false" value="wish"/>
</div>@keyframes field-constellation-star { from { transform: scale(0); opacity: 0; } 60% { transform: scale(1.8); opacity: 1; } }
@keyframes field-constellation-twinkle { 50% { opacity: 0.55; } }
@keyframes field-constellation-line { to { stroke-dashoffset: 0; } }
.field-constellation { width: 256px; }
.field-constellation__sky {
position: relative;
height: 112px;
margin-bottom: 8px;
border-radius: 12px;
background: radial-gradient(ellipse at 50% 120%, light-dark(#ebedf0, #232529), light-dark(#fbfcfd, #121417) 70%);
box-shadow: inset 0 0 0 1px light-dark(#ebedf0, #232529);
overflow: hidden;
}
.field-constellation__sky svg { position: absolute; inset: 0; width: 256px; height: 112px; }
.field-constellation__line { fill: none; stroke: light-dark(#5f626a, #a7abb2); stroke-width: 0.8; stroke-dasharray: 1; stroke-dashoffset: 1; animation: field-constellation-line 0.5s ease-out 0.1s forwards; }
.field-constellation__star { fill: light-dark(#373a40, #f1f2f3); transform-box: fill-box; transform-origin: center; animation: field-constellation-star 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) both, field-constellation-twinkle 2.6s ease-in-out 0.5s infinite; }
.field-constellation__fig { transition: transform 0.4s cubic-bezier(0.2, 0.8, 0.3, 1); }
.field-constellation__dust { fill: light-dark(#cbced3, #393c41); }
.field-constellation__name { position: absolute; left: 10px; bottom: 7px; color: light-dark(#5f626a, #a7abb2); font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; }
.field-constellation__label { display: block; margin-bottom: 6px; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-constellation__input {
width: 100%;
height: 38px;
padding: 0 12px;
border: 0;
outline: 0;
border-radius: 9px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
color: light-dark(#373a40, #f1f2f3);
font-family: inherit; font-size: 14px;
transition: box-shadow 0.2s;
}
.field-constellation__input:focus { box-shadow: inset 0 0 0 1px light-dark(#5f626a, #a7abb2); }
@media (prefers-reduced-motion: reduce) { .field-constellation__line, .field-constellation__star { animation: none; stroke-dashoffset: 0; } }function initFieldConstellation(root) {
var input = root.querySelector('.field-constellation__input');
var svg = root.querySelector('.field-constellation__sky svg');
var fig = root.querySelector('.field-constellation__fig');
var name = root.querySelector('.field-constellation__name');
if (!input || !svg || !fig || !name) return;
var NS = 'http://www.w3.org/2000/svg';
var W = 256;
var H = 112;
var STEP = 9.4;
function hash(n) {
var x = Math.sin(n * 127.1 + 311.7) * 43758.5453;
return x - Math.floor(x);
}
function r2(n) { return Math.round(n * 100) / 100; }
var value = input.value;
function star(c, i) {
return { c: c, x: r2(18 + i * STEP + (hash(i + 7) - 0.5) * 6), y: r2(16 + hash(c.charCodeAt(0) * 3.1 + i * 0.7) * (H - 40)), r: r2(1.3 + hash(c.charCodeAt(0)) * 1.6) };
}
// Stars and links keyed by position and letters, so unchanged ones stay and only new ones animate in.
function render() {
var next = input.value;
var stars = next.split('').map(star);
var keep = {};
stars.forEach(function (s, i) {
if (s.c === ' ') return;
keep['s' + i + s.c] = s;
var p = stars[i - 1];
if (p && p.c !== ' ') keep['l' + i + s.c + p.c] = [p, s];
});
Array.prototype.slice.call(fig.children).forEach(function (el) {
if (!keep[el.getAttribute('data-k')]) el.remove();
else delete keep[el.getAttribute('data-k')];
});
Object.keys(keep).forEach(function (k) {
var v = keep[k];
var el;
if (k.charAt(0) === 'l') {
el = document.createElementNS(NS, 'line');
el.setAttribute('class', 'field-constellation__line');
el.setAttribute('x1', v[0].x);
el.setAttribute('y1', v[0].y);
el.setAttribute('x2', v[1].x);
el.setAttribute('y2', v[1].y);
el.setAttribute('pathLength', '1');
fig.insertBefore(el, fig.firstChild);
} else {
el = document.createElementNS(NS, 'circle');
el.setAttribute('class', 'field-constellation__star');
el.setAttribute('cx', v.x);
el.setAttribute('cy', v.y);
el.setAttribute('r', v.r);
el.style.animationDelay = '0s, ' + (0.5 + hash(Number(k.slice(1, -1))) * 2).toFixed(2) + 's';
fig.appendChild(el);
}
el.setAttribute('data-k', k);
});
// The whole figure stays centred in the sky, gliding over as it grows.
fig.style.transform = 'translateX(' + (W - 36 - Math.max(0, next.length - 1) * STEP) / 2 + 'px)';
name.textContent = next.replace(/\s/g, '').length + ' stars';
value = next;
}
// Tag the server-rendered stars and links with their keys.
var initial = value.split('').map(star);
var lines = fig.querySelectorAll('line');
var circles = fig.querySelectorAll('circle');
var li = 0;
var ci = 0;
initial.forEach(function (s, i) {
if (s.c === ' ') return;
var p = initial[i - 1];
if (p && p.c !== ' ' && lines[li]) lines[li++].setAttribute('data-k', 'l' + i + s.c + p.c);
if (circles[ci]) circles[ci++].setAttribute('data-k', 's' + i + s.c);
});
input.addEventListener('input', render);
return function () { input.removeEventListener('input', render); };
}
document.querySelectorAll('.field-constellation').forEach(initFieldConstellation);Goal: Create a "Constellation" input.
Visual behavior: Make a wish: every letter you type becomes a star in the night sky above the field, joined to the previous one by a line that draws in, and the whole figure glides to stay centred as it grows.
Technique:
- Each letter becomes a star: across by its position, up and down by a hash of the letter and position, size by the letter; spaces break the line.
- Stars pop in with an overshoot and then twinkle; each link is a line with pathLength 1 whose dashoffset draws it in.
- The figure is one SVG group translated by (sky width - figure width) / 2 with a transition, so it glides to stay centred.
Accessibility: A real <input> with a visible <label>; decoration is aria-hidden. The sky is aria-hidden. Under prefers-reduced-motion stars and lines appear without animation.
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" input.
Visual behavior: Make a wish: every letter you type becomes a star in the night sky above the field, joined to the previous one by a line that draws in, and the whole figure glides to stay centred as it grows.
Technique:
- Each letter becomes a star: across by its position, up and down by a hash of the letter and position, size by the letter; spaces break the line.
- Stars pop in with an overshoot and then twinkle; each link is a line with pathLength 1 whose dashoffset draws it in.
- The figure is one SVG group translated by (sky width - figure width) / 2 with a transition, so it glides to stay centred.
Accessibility: A real <input> with a visible <label>; decoration is aria-hidden. The sky is aria-hidden. Under prefers-reduced-motion stars and lines appear without animation.
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
Make a wish: every letter you type becomes a star in the night sky above the field, joined to the previous one by a line that draws in, and the whole figure glides to stay centred as it grows.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- focus, draw, glow, state change
- Techniques
- svg, stroke dashoffset, keyframes, css transition
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A real <input> with a visible <label>; decoration is aria-hidden. The sky is aria-hidden. Under prefers-reduced-motion stars and lines appear without animation.