Install
That writes one file:compositions/components/state-chip-rail.html.
Paste it into your composition
Opencompositions/components/state-chip-rail.html and copy what is inside into your own composition.
A component has no size or duration of its own. It takes both from the composition
you paste it into.
Variables
Every one of these has a default, so the piece works untouched. Set the ones you want to change on the element:| Variable | Default | Accepts | What it does |
|---|---|---|---|
states | Queued,Reading,Drafting,Done | string | Comma-separated chip labels in machine order (2 to 8 states). |
times | “ | string | Comma-separated activation times in seconds, one per advance (state 1 onward). Blank entries use the default even rhythm. |
badge_state | 1 | 0 to 7, step 1 | Index of the state the badges pop beside. |
badges | “ | string | Comma-separated badge labels shown beside the badge state. Empty disables. |
accent | green | green, blue, violet | Accent token family used by the active chip. |
exit | none | none, fade, up | How the rail leaves the frame. Default none: the hold ends the film. |
data-variable-values on the element that mounts it. These are the
defaults, so this behaves exactly like the preview above until you change one:
<div
data-composition-id="state-chip-rail"
data-composition-src="compositions/components/state-chip-rail.html"
data-variable-values='{"states":"Queued,Reading,Drafting,Done","times":"","badge_state":1,"badges":"","accent":"green","exit":"none"}'
></div>
Source
state-chip-rail.html
state-chip-rail.html
<!doctype html>
<!--
state-chip-rail: HyperFrames video primitive (product demo / state machine)
A rail of mono status chips advances a data-state snap machine on a cue
schedule. CSS alone responds to each chip's [data-state]; the timeline only
flips attributes with tl.set, so every advance is an instantaneous snap that
is exact in both seek directions (GSAP zero-duration sets record the prior
attribute value and restore it on backward seeks). The active chip is ink on
surface, done chips dim, pending chips stay hollow. Optional badges pop
beside one named state when it activates. Generalizes the flagship
agent-console status rail (cohere-north frame 04).
Variables (declared in data-composition-variables below):
- states (string, default "Queued,Reading,Drafting,Done"): comma list of
chip labels, in machine order. 2 to 8 states; extras are dropped.
- times (string, default ""): comma-separated activation times in seconds
relative to mount start, one per ADVANCE (state 1, state 2, ...).
Blank or invalid entries use the default even rhythm. Values are
clamped into the mount window and forced monotonic.
- badge_state (number, default 1): index of the state the badges pop
beside. Clamped to the states range.
- badges (string, default ""): comma list of badge labels rendered after
the badge_state chip; they pop in when that state activates. Empty
disables badges. Up to 4.
- accent (green | blue | violet, default green): active-chip accent.
green -> --brand, blue -> --accent, violet -> --accent-2.
- exit ("none" | "fade" | "up", default "none"): frame roots own
transitions; the default hold-ends-the-film.
Envelope, fixed IN with elastic HOLD (never gsap.timeScale()):
IN = chip cascade (~0.8s) then the advance schedule; the default
rhythm spreads advances so the final state lands ~1s before the
mount window closes.
HOLD = still after the last advance; nothing floats, nothing loops.
OUT = 0 when exit is "none"; else 0.45s fade / fade-up of the rail.
Mount contract: elastic root, no data-width/data-height, styled via #root
only, container-type: size, one paused timeline registered under the
literal "state-chip-rail" key. Deterministic and seek-safe: fromTo
entrances, attribute snaps via tl.set, no CSS transitions, no Date.now,
no randomness. State dimming rides color (color-mix), never opacity, so it
can never collide with the entrance opacity tweens.
-->
<html
lang="en"
data-composition-id="state-chip-rail"
data-composition-duration="5"
data-composition-variables='[
{ "id": "states", "type": "string", "role": "content", "label": "States", "description": "Comma-separated chip labels in machine order (2 to 8 states).", "default": "Queued,Reading,Drafting,Done" },
{ "id": "times", "type": "string", "role": "timing", "label": "Times", "description": "Comma-separated activation times in seconds, one per advance (state 1 onward). Blank entries use the default even rhythm.", "default": "" },
{ "id": "badge_state", "type": "number", "role": "content", "label": "Badge state", "description": "Index of the state the badges pop beside.", "default": 1, "min": 0, "max": 7, "step": 1 },
{ "id": "badges", "type": "string", "role": "content", "label": "Badges", "description": "Comma-separated badge labels shown beside the badge state. Empty disables.", "default": "" },
{ "id": "accent", "type": "enum", "role": "style", "label": "Accent", "description": "Accent token family used by the active chip.", "default": "green", "options": [{ "value": "green", "label": "Green" }, { "value": "blue", "label": "Blue" }, { "value": "violet", "label": "Violet" }] },
{ "id": "exit", "type": "enum", "role": "timing", "label": "Exit", "description": "How the rail leaves the frame. Default none: the hold ends the film.", "default": "none", "options": [{ "value": "none", "label": "None" }, { "value": "fade", "label": "Fade" }, { "value": "up", "label": "Up" }] }
]'
>
<head>
<meta charset="UTF-8" />
<title>State Chip Rail</title>
</head>
<body>
<template>
<div id="root" data-composition-id="state-chip-rail" data-duration="5" data-fps="30">
<style>
*,
*::before,
*::after {
box-sizing: border-box;
}
#root {
position: absolute;
inset: 0;
overflow: hidden;
container-type: size;
isolation: isolate;
background: var(--bg, transparent);
color: var(--fg, #f8fafc);
font-family: var(--font-mono, ui-monospace, "SF Mono", Menlo, Consolas, monospace);
pointer-events: none;
}
.scr-clip {
position: absolute;
inset: 0;
display: grid;
place-items: center;
overflow: hidden;
}
.scr-rail {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: nowrap;
max-width: 94cqw;
gap: var(--scr-gap, 0.8em);
font-size: var(--scr-font, 2cqmin);
will-change: transform, opacity;
}
.scr-chip,
.scr-badge {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 0.55em;
padding: 0.62em 1.05em;
overflow: hidden;
border: 0.16cqmin solid var(--border, #334155);
border-radius: 100cqmax;
font-weight: 500;
line-height: 1;
letter-spacing: 0.14em;
text-transform: uppercase;
white-space: nowrap;
will-change: transform, opacity;
}
.scr-dot {
flex: 0 0 auto;
width: 0.5em;
aspect-ratio: 1;
border: 0.16cqmin solid var(--muted, #94a3b8);
border-radius: 50%;
background: transparent;
}
/* The snap machine: presentation is pure CSS on [data-state]; the
timeline only flips the attribute. Dimming rides color, never
opacity, so it cannot fight the entrance opacity tweens. */
.scr-chip[data-state="pending"] {
border-color: color-mix(in srgb, var(--border, #334155) 80%, transparent);
background: transparent;
color: color-mix(in srgb, var(--muted, #94a3b8) 78%, transparent);
}
.scr-chip[data-state="active"] {
border-color: color-mix(in srgb, var(--scr-accent) 62%, var(--border, #334155));
background: var(--surface, #131a24);
color: var(--fg, #f8fafc);
}
.scr-chip[data-state="active"] .scr-dot {
border-color: transparent;
background: var(--scr-accent, var(--brand, #71f5a7));
}
.scr-chip[data-state="done"] {
border-color: color-mix(in srgb, var(--border, #334155) 45%, transparent);
background: transparent;
color: color-mix(in srgb, var(--muted, #94a3b8) 42%, transparent);
}
.scr-chip[data-state="done"] .scr-dot {
border-color: transparent;
background: color-mix(in srgb, var(--scr-accent, #71f5a7) 38%, transparent);
}
.scr-badge {
padding: 0.5em 0.9em;
border-color: color-mix(in srgb, var(--border, #334155) 70%, transparent);
color: color-mix(in srgb, var(--muted, #94a3b8) 88%, transparent);
font-size: 0.82em;
}
</style>
<div
id="state-chip-rail-clip"
class="scr-clip clip"
data-start="0"
data-duration="5"
data-track-index="0"
>
<div class="scr-rail" role="list"></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script>
(function () {
"use strict";
// Literal id, never read off the DOM: the flattening step strips
// data-composition-id from the mounted root (see toggle-flip).
var compositionId = "state-chip-rail";
var root = document.getElementById("root");
var rail = root.querySelector(".scr-rail");
var vars =
window.__hyperframes && window.__hyperframes.getVariables
? window.__hyperframes.getVariables()
: {};
function commaList(value, max) {
return String(value == null ? "" : value)
.split(",")
.map(function (token) {
return token.trim();
})
.filter(function (token) {
return token !== "";
})
.slice(0, max);
}
var states = commaList(vars.states, 8);
if (states.length === 0) states = ["Queued", "Reading", "Drafting", "Done"];
var count = states.length;
var badges = commaList(vars.badges, 4);
var badgeStateRaw = Math.round(Number(vars.badge_state));
var badgeState = Number.isFinite(badgeStateRaw)
? Math.max(0, Math.min(count - 1, badgeStateRaw))
: Math.min(1, count - 1);
var exit = vars.exit === "fade" || vars.exit === "up" ? vars.exit : "none";
// Each enum choice routes to a DIFFERENT contract token so the
// variable stays meaningful under a theme.
var accentColors = {
green: "var(--brand, #71f5a7)",
blue: "var(--accent, #61a8ff)",
violet: "var(--accent-2, #c5a3ff)",
};
var accent = Object.prototype.hasOwnProperty.call(accentColors, vars.accent)
? vars.accent
: "green";
root.style.setProperty("--scr-accent", accentColors[accent]);
// Build the rail: chips in machine order, badges directly after
// the badge_state chip. State 0 is active from mount.
var chips = states.map(function (label, index) {
var chip = document.createElement("div");
chip.className = "scr-chip";
chip.setAttribute("role", "listitem");
chip.setAttribute("data-state", index === 0 ? "active" : "pending");
var dot = document.createElement("span");
dot.className = "scr-dot";
dot.setAttribute("aria-hidden", "true");
var text = document.createElement("span");
text.textContent = label;
chip.appendChild(dot);
chip.appendChild(text);
return chip;
});
var badgeElements = badges.map(function (label) {
var badge = document.createElement("div");
badge.className = "scr-badge";
badge.textContent = label;
return badge;
});
chips.forEach(function (chip, index) {
rail.appendChild(chip);
if (index === badgeState) {
badgeElements.forEach(function (badge) {
rail.appendChild(badge);
});
}
});
rail.setAttribute("aria-label", states.join(", "));
// Deterministic fit: wide-tracked mono at ~0.76em per glyph, plus
// chip padding, dot, and rail gaps, all measured in em.
var railEm = (count - 1 + badges.length) * 0.8;
states.forEach(function (label) {
railEm += Array.from(label).length * 0.76 + 3.1;
});
badges.forEach(function (label) {
railEm += (Array.from(label).length * 0.76 + 2.5) * 0.82;
});
var fontCqw = Math.min(2.4, 90 / Math.max(1, railEm));
root.style.setProperty("--scr-font", "min(" + fontCqw.toFixed(3) + "cqw, 9cqh)");
// RETIME RANGE: fixed IN cascade + advance schedule, elastic
// still HOLD, optional OUT.
var OUT_BASE = 0.45;
var duration = Math.max(0.001, parseFloat(root.dataset.duration || "5"));
var OUT = exit === "none" ? 0 : Math.min(OUT_BASE, duration * 0.2);
var avail = duration - OUT;
var CHIP = Math.min(0.45, Math.max(0.2, avail * 0.15));
var entryGap = Math.min(0.08, Math.max(0.02, avail * 0.02));
var entryEnd = 0.12 + entryGap * (count - 1) + CHIP;
// Default rhythm: advances spread evenly from just after the
// cascade to ~1s before the window closes.
var firstAdvance = Math.min(Math.max(entryEnd + 0.15, 0.9), Math.max(0.2, avail - 0.6));
var lastAdvance = Math.max(firstAdvance, avail - 1.0);
var advanceCount = count - 1;
var timesList = String(vars.times == null ? "" : vars.times).split(",");
var previous = 0.15;
var advances = [];
for (var k = 0; k < advanceCount; k += 1) {
var fallback =
advanceCount > 1
? firstAdvance + (k * (lastAdvance - firstAdvance)) / (advanceCount - 1)
: firstAdvance;
var cueText = (timesList[k] || "").trim();
var cue = Number(cueText);
var start = cueText !== "" && Number.isFinite(cue) ? cue : fallback;
start = Math.max(0.15, Math.min(start, Math.max(0.15, avail - 0.45)));
start = Math.max(start, previous + 0.05);
previous = start;
advances.push(start);
}
var tl = gsap.timeline({ paused: true });
// IN: chips cascade into the rail. Explicit both-endpoints state
// so any seek is exact. Badges hold at scale 0 until their pop
// (they keep their layout slot, so the rail never reflows).
chips.forEach(function (chip, index) {
gsap.set(chip, { opacity: 0, y: "2.5cqh" });
tl.fromTo(
chip,
{ opacity: 0, y: "2.5cqh" },
{ opacity: 1, y: "0cqh", duration: CHIP, ease: "power3.out" },
0.12 + entryGap * index,
);
});
// The snap machine: pure attribute flips. GSAP records the prior
// value on first render in timeline order, so backward seeks
// restore pending / active exactly.
advances.forEach(function (start, index) {
var stateIndex = index + 1;
tl.set(chips[stateIndex], { attr: { "data-state": "active" } }, start);
tl.set(chips[stateIndex - 1], { attr: { "data-state": "done" } }, start);
});
// Badges pop beside their state as it activates.
if (badgeElements.length > 0) {
var popStart = badgeState === 0 ? entryEnd + 0.1 : advances[badgeState - 1] + 0.12;
popStart = Math.min(popStart, Math.max(0.2, avail - 0.4));
badgeElements.forEach(function (badge, index) {
gsap.set(badge, { opacity: 0, scale: 0 });
tl.fromTo(
badge,
{ opacity: 0, scale: 0 },
{ opacity: 1, scale: 1, duration: 0.4, ease: "power3.out" },
popStart + 0.14 * index,
);
});
}
// HOLD: intentionally still. The resolved machine reads as a frame.
// OUT: the whole rail leaves together.
if (exit !== "none") {
var OUT_START = duration - OUT;
tl.to(rail, { opacity: 0, duration: OUT, ease: "power2.in" }, OUT_START);
if (exit === "up") {
tl.to(rail, { y: "-3cqh", duration: OUT, ease: "power2.in" }, OUT_START);
}
}
tl.seek(0);
window.__timelines = window.__timelines || {};
window.__timelines[compositionId] = tl;
})();
</script>
</div>
</template>
</body>
</html>
motion-primitive product-demo state-machine status chips rail.