Annotation Arrow
A note that draws a hand-drawn arrow to the element it points at, picked by a CSS selector. The curve avoids both boxes and redraws when the layout moves.
View codeA line drawn through the steps of a section as you read: it swings from step to step, fills each marker it reaches and ends with a ring around the button.
With your AI
Say it to your AI. It gets the exact code below, not its best guess, and knows where it goes.
Add the Kodza Scroll Path to my page.
claude mcp add -t http kodza https://kodza.dev/api/mcp
<div class="kodza-scroll-path">
<ol class="kodza-scroll-path__list">
<li class="kodza-scroll-path__stop" data-path-stop>
<span class="kodza-scroll-path__marker" data-path-marker aria-hidden="true">01</span>
<div class="kodza-scroll-path__body">
<h3 class="kodza-scroll-path__title">Find it</h3>
<p class="kodza-scroll-path__text">Search the catalogue, or ask your AI. It gets the exact files, not a guess at them.</p>
</div>
</li>
<li class="kodza-scroll-path__stop" data-path-stop>
<span class="kodza-scroll-path__marker" data-path-marker aria-hidden="true">02</span>
<div class="kodza-scroll-path__body">
<h3 class="kodza-scroll-path__title">Copy it</h3>
<p class="kodza-scroll-path__text">HTML, CSS, a few lines of JS. No package, no build step, no account to create.</p>
</div>
</li>
<li class="kodza-scroll-path__stop" data-path-stop>
<span class="kodza-scroll-path__marker" data-path-marker aria-hidden="true">03</span>
<div class="kodza-scroll-path__body">
<h3 class="kodza-scroll-path__title">Paste it</h3>
<p class="kodza-scroll-path__text">WordPress, Webflow, React or a plain page. It keeps to its own class and leaves yours alone.</p>
</div>
</li>
<li class="kodza-scroll-path__stop" data-path-stop>
<span class="kodza-scroll-path__marker" data-path-marker aria-hidden="true">04</span>
<div class="kodza-scroll-path__body">
<h3 class="kodza-scroll-path__title">Tune it</h3>
<p class="kodza-scroll-path__text">Colours, sizes and timings are variables at the top of the file. Change three, keep the rest.</p>
</div>
</li>
<li class="kodza-scroll-path__stop" data-path-stop>
<span class="kodza-scroll-path__marker" data-path-marker aria-hidden="true">05</span>
<div class="kodza-scroll-path__body">
<h3 class="kodza-scroll-path__title">Ship it</h3>
<p class="kodza-scroll-path__text">Tested in three themes before you saw it. It works on more than my machine.</p>
</div>
</li>
</ol>
<div class="kodza-scroll-path__end" data-path-stop>
<a class="kodza-scroll-path__cta" href="#" data-path-ring>Browse components</a>
<p class="kodza-scroll-path__note">Free. No sign-up.</p>
</div>
</div>
/* Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license */
/* Kodza — Scroll Path
One line drawn through the steps of a section as you read. It runs down beside each step,
swings across to the next one in an S, inks each marker it reaches, and ends by drawing a
ring around the button. The dotted route is there from the start: the plan, then the ink.
Without the script, the steps are simply there. Everything is tuned with the
--kz-scroll-path-* variables (the route itself with three attributes, see component.js). */
.kodza-scroll-path {
--kz-scroll-path-fg: #0a0a0a;
--kz-scroll-path-muted: rgba(10, 10, 10, 0.62);
--kz-scroll-path-bg: #f2f2f0; /* the page behind the markers and around the pen */
--kz-scroll-path-ink: #0a0a0a; /* the line, the pen and the markers it reached */
--kz-scroll-path-on-ink: #ffffff;
--kz-scroll-path-track: rgba(10, 10, 10, 0.26); /* the dotted route, before the ink */
--kz-scroll-path-rim: rgba(10, 10, 10, 0.16); /* a marker not reached yet */
--kz-scroll-path-width: 2px; /* thickness of the line */
--kz-scroll-path-marker: 44px;
--kz-scroll-path-column: 440px; /* width of a step */
--kz-scroll-path-gap: 200px; /* between two steps: the swings live there */
--kz-scroll-path-ease: cubic-bezier(0.22, 1, 0.36, 1);
position: relative;
isolation: isolate;
color: var(--kz-scroll-path-fg);
}
/* The drawing, laid by the script under the steps: a block with a background hides the line
that passes behind it */
.kodza-scroll-path__svg {
position: absolute;
top: 0;
left: 0;
overflow: visible;
pointer-events: none;
}
.kodza-scroll-path__track,
.kodza-scroll-path__ink {
fill: none;
stroke-width: var(--kz-scroll-path-width);
stroke-linecap: round;
stroke-linejoin: round;
}
.kodza-scroll-path__track {
stroke: var(--kz-scroll-path-track);
stroke-dasharray: 0 8;
}
.kodza-scroll-path__ink {
stroke: var(--kz-scroll-path-ink);
}
.kodza-scroll-path__pen {
fill: var(--kz-scroll-path-ink);
stroke: var(--kz-scroll-path-bg);
stroke-width: 3;
transition: opacity calc(0.3s * var(--kz-motion-scale, 1)) ease;
}
.kodza-scroll-path__pen.is-away {
opacity: 0;
}
/* The steps, one on each side (a step's width short of the full width on a phone, so the
line still swings) */
.kodza-scroll-path__list {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: var(--kz-scroll-path-gap);
margin: 0;
padding: 0;
list-style: none;
}
.kodza-scroll-path__stop {
display: grid;
grid-template-columns: var(--kz-scroll-path-marker) minmax(0, 1fr);
column-gap: 22px;
align-items: start;
width: min(calc(100% - var(--kz-scroll-path-marker)), var(--kz-scroll-path-column));
margin: 0;
padding: 0;
}
.kodza-scroll-path__stop:nth-child(even) {
align-self: flex-end;
}
/* The marker the line passes through: it fills with ink from its centre when the line reaches it */
.kodza-scroll-path__marker {
position: relative;
z-index: 0;
display: grid;
place-items: center;
width: var(--kz-scroll-path-marker);
height: var(--kz-scroll-path-marker);
border-radius: 50%;
background: var(--kz-scroll-path-bg);
box-shadow: inset 0 0 0 1px var(--kz-scroll-path-rim);
color: var(--kz-scroll-path-muted);
font-family: var(--kz-font-label, var(--kz-font-mono, "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace));
font-size: var(--kz-font-label-size, 12px);
font-weight: var(--kz-font-label-weight, inherit);
line-height: 1;
letter-spacing: var(--kz-font-label-tracking, inherit);
transition: color calc(0.35s * var(--kz-motion-scale, 1)) ease;
}
.kodza-scroll-path__marker::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
border-radius: inherit;
background: var(--kz-scroll-path-ink);
transform: scale(0);
transition: transform calc(0.5s * var(--kz-motion-scale, 1)) cubic-bezier(0.34, 1.4, 0.64, 1);
}
.kodza-scroll-path__stop.is-reached .kodza-scroll-path__marker {
color: var(--kz-scroll-path-on-ink);
}
.kodza-scroll-path__stop.is-reached .kodza-scroll-path__marker::before {
transform: scale(1);
}
.kodza-scroll-path__body {
display: grid;
gap: 10px;
padding-top: 9px;
}
.kodza-scroll-path__title {
margin: 0;
font-family: var(--kz-font-display, inherit);
font-size: calc(24px * var(--kz-font-display-scale, 1));
font-weight: var(--kz-font-display-weight, 600);
line-height: 1.1;
letter-spacing: var(--kz-font-display-tracking, -0.03em);
text-transform: var(--kz-font-display-case, inherit);
}
.kodza-scroll-path__text {
max-width: 40ch;
margin: 0;
color: var(--kz-scroll-path-muted);
font-size: 15px;
line-height: 1.6;
text-wrap: pretty;
}
/* The end: the line arrives on top of the button and draws its ring */
.kodza-scroll-path__end {
position: relative;
z-index: 1;
display: grid;
justify-items: center;
gap: 22px;
margin-top: var(--kz-scroll-path-gap);
text-align: center;
}
.kodza-scroll-path__cta {
display: inline-flex;
align-items: center;
height: 52px;
padding: 0 28px;
border-radius: var(--kz-radius-pill, 999px);
background: var(--kz-scroll-path-ink);
color: var(--kz-scroll-path-on-ink);
font-family: var(--kz-font-label, inherit);
font-size: var(--kz-font-label-size, 15px);
font-weight: var(--kz-font-label-weight, 500);
line-height: 1;
letter-spacing: var(--kz-font-label-tracking, -0.01em);
text-transform: var(--kz-font-label-case, revert);
text-decoration: none;
white-space: nowrap;
-webkit-tap-highlight-color: transparent;
transition: transform calc(0.25s * var(--kz-motion-scale, 1)) var(--kz-scroll-path-ease);
}
.kodza-scroll-path__cta:active {
transform: scale(0.97);
}
.kodza-scroll-path__cta:focus-visible {
outline: 2px solid var(--kz-scroll-path-ink);
outline-offset: 3px;
}
.kodza-scroll-path__note {
margin: 0;
color: var(--kz-scroll-path-muted);
font-family: var(--kz-font-label, var(--kz-font-mono, "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace));
font-size: var(--kz-font-label-size, 12px);
font-weight: var(--kz-font-label-weight, inherit);
line-height: var(--kz-font-label-leading, 1.4);
letter-spacing: var(--kz-font-label-tracking, inherit);
text-transform: var(--kz-font-label-case, inherit);
}
/* Each step arrives the first time it is seen (only once the script is there): the marker
grows, the title rises from behind its own edge (the clip moves against the text, so its
window stays put), then the text fades up */
.kodza-scroll-path__marker,
.kodza-scroll-path__title,
.kodza-scroll-path__text {
transition:
color calc(0.35s * var(--kz-motion-scale, 1)) ease,
opacity calc(0.6s * var(--kz-motion-scale, 1)) ease,
transform calc(0.8s * var(--kz-motion-scale, 1)) var(--kz-scroll-path-ease),
clip-path calc(0.8s * var(--kz-motion-scale, 1)) var(--kz-scroll-path-ease);
}
.kodza-scroll-path__title {
clip-path: inset(-20% -20% -20% -20%);
}
.kodza-scroll-path.is-ready .kodza-scroll-path__stop:not(.is-seen) .kodza-scroll-path__marker {
opacity: 0;
transform: scale(0.6);
}
.kodza-scroll-path.is-ready .kodza-scroll-path__stop:not(.is-seen) .kodza-scroll-path__title {
transform: translateY(120%);
clip-path: inset(-140% -20% 100% -20%);
}
.kodza-scroll-path.is-ready .kodza-scroll-path__stop:not(.is-seen) .kodza-scroll-path__text {
opacity: 0;
transform: translateY(14px);
}
.kodza-scroll-path__stop.is-seen .kodza-scroll-path__title {
transition-delay: 0s, 0s, calc(0.08s * var(--kz-motion-scale, 1)), calc(0.08s * var(--kz-motion-scale, 1));
}
.kodza-scroll-path__stop.is-seen .kodza-scroll-path__text {
transition-delay: 0s, calc(0.2s * var(--kz-motion-scale, 1)), calc(0.2s * var(--kz-motion-scale, 1)), 0s;
}
@media (hover: hover) {
.kodza-scroll-path__cta:hover {
transform: translateY(-1px);
}
}
@media (prefers-reduced-motion: reduce) {
.kodza-scroll-path *,
.kodza-scroll-path *::before,
.kodza-scroll-path *::after {
transition-duration: 0.01ms !important;
transition-delay: 0s !important;
}
}
/* kodza-guard 8bc520d851a4 — generated by Kodza, do not edit.
Theme guard: keeps a site's own CSS (WordPress themes, Bootstrap, CSS resets…) off this
component. :not(#_) is always true and weighs as much as an id, so these lines win over
theme rules made of classes and tags. Customise the component through its --kz-* variables. */
/* What the browser gives, where the component sets it only in some states: at the lowest weight, so its own rules below put it back */
:where(.kodza-scroll-path__svg):not(#_),
:where(.kodza-scroll-path__list):not(#_),
:where(.kodza-scroll-path__stop):not(#_),
:where(.kodza-scroll-path__note):not(#_) {
transition-duration: revert;
transition-delay: revert;
}
:where(.kodza-scroll-path__cta):not(#_) {
outline: revert;
outline-offset: revert;
}
/* What the browser gives, on everything the component leaves to it */
.kodza-scroll-path:not(#_),
.kodza-scroll-path__track:not(#_),
.kodza-scroll-path__ink:not(#_),
.kodza-scroll-path__pen:not(#_),
.kodza-scroll-path__marker:not(#_),
.kodza-scroll-path__body:not(#_),
.kodza-scroll-path__end:not(#_) {
box-sizing: revert;
}
.kodza-scroll-path__svg:not(#_) {
border: revert;
margin: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
transition-property: revert;
transition-timing-function: revert;
}
.kodza-scroll-path__list:not(#_) {
border: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
box-sizing: revert;
float: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
transition-property: revert;
transition-timing-function: revert;
}
.kodza-scroll-path__stop:not(#_) {
border: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
transition-property: revert;
transition-timing-function: revert;
}
.kodza-scroll-path__title:not(#_) {
border: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-style: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-scroll-path__text:not(#_) {
border: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-height: revert;
font-family: revert;
font-weight: revert;
font-style: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-scroll-path__cta:not(#_) {
border: revert;
margin: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
box-shadow: revert;
appearance: revert;
max-width: revert;
max-height: revert;
font-style: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-scroll-path__note:not(#_) {
border: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
font-style: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
transition-property: revert;
transition-timing-function: revert;
}
.kodza-scroll-path__svg:not(#_):where(:not([hidden])),
.kodza-scroll-path__title:not(#_):where(:not([hidden])),
.kodza-scroll-path__text:not(#_):where(:not([hidden])),
.kodza-scroll-path__note:not(#_):where(:not([hidden])) {
display: revert;
}
.kodza-scroll-path__svg:not(#_):where([hidden]),
.kodza-scroll-path__title:not(#_):where([hidden]),
.kodza-scroll-path__text:not(#_):where([hidden]),
.kodza-scroll-path__note:not(#_):where([hidden]) {
display: none;
}
.kodza-scroll-path__marker:not(#_)::before {
box-sizing: revert;
}
/* The component's own rules, one step stronger: real buttons and fields, what it sets on tags themes style (links, lists, headings, images…), and the root width (a theme may narrow it, never widen it) */
.kodza-scroll-path__svg:not(#_) {
position: absolute;
}
.kodza-scroll-path__list:not(#_) {
position: relative;
display: flex;
margin: 0;
padding: 0;
list-style: none;
}
.kodza-scroll-path__stop:not(#_) {
display: grid;
margin: 0;
padding: 0;
}
.kodza-scroll-path__title:not(#_) {
margin: 0;
font-family: var(--kz-font-display, inherit);
font-size: calc(24px * var(--kz-font-display-scale, 1));
font-weight: var(--kz-font-display-weight, 600);
line-height: 1.1;
letter-spacing: var(--kz-font-display-tracking, -0.03em);
text-transform: var(--kz-font-display-case, inherit);
}
.kodza-scroll-path__text:not(#_) {
max-width: 40ch;
margin: 0;
color: var(--kz-scroll-path-muted);
font-size: 15px;
line-height: 1.6;
}
.kodza-scroll-path__cta:not(#_) {
display: inline-flex;
padding: 0 28px;
border-radius: var(--kz-radius-pill, 999px);
background: var(--kz-scroll-path-ink);
color: var(--kz-scroll-path-on-ink);
font-family: var(--kz-font-label, inherit);
font-size: var(--kz-font-label-size, 15px);
font-weight: var(--kz-font-label-weight, 500);
line-height: 1;
letter-spacing: var(--kz-font-label-tracking, -0.01em);
text-transform: var(--kz-font-label-case, revert);
text-decoration: none;
transition: transform calc(0.25s * var(--kz-motion-scale, 1)) var(--kz-scroll-path-ease);
}
.kodza-scroll-path__cta:focus-visible:not(#_) {
outline: 2px solid var(--kz-scroll-path-ink);
outline-offset: 3px;
}
.kodza-scroll-path__note:not(#_) {
margin: 0;
color: var(--kz-scroll-path-muted);
font-family: var(--kz-font-label, var(--kz-font-mono, "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace));
font-size: var(--kz-font-label-size, 12px);
font-weight: var(--kz-font-label-weight, inherit);
line-height: var(--kz-font-label-leading, 1.4);
letter-spacing: var(--kz-font-label-tracking, inherit);
text-transform: var(--kz-font-label-case, inherit);
}
.kodza-scroll-path__title:not(#_),
.kodza-scroll-path__text:not(#_) {
transition: color calc(0.35s * var(--kz-motion-scale, 1)) ease,
opacity calc(0.6s * var(--kz-motion-scale, 1)) ease,
transform calc(0.8s * var(--kz-motion-scale, 1)) var(--kz-scroll-path-ease),
clip-path calc(0.8s * var(--kz-motion-scale, 1)) var(--kz-scroll-path-ease);
}
.kodza-scroll-path__stop.is-seen .kodza-scroll-path__title:not(#_) {
transition-delay: 0s, 0s, calc(0.08s * var(--kz-motion-scale, 1)), calc(0.08s * var(--kz-motion-scale, 1));
}
.kodza-scroll-path__stop.is-seen .kodza-scroll-path__text:not(#_) {
transition-delay: 0s, calc(0.2s * var(--kz-motion-scale, 1)), calc(0.2s * var(--kz-motion-scale, 1)), 0s;
}
@media (prefers-reduced-motion: reduce) {
.kodza-scroll-path *:where(svg, ol, li, h3, p, a):not(#_) {
transition-duration: 0.01ms !important;
transition-delay: 0s !important;
}
}
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Kodza — Scroll Path
// Draws one line through the steps of a section, up to the reading point: down beside each
// step, across to the next one in an S, then a ring around the button. The route is measured
// from the real blocks and redrawn whenever they change size. Three attributes lay it out:
// data-path-stop on each block the line visits, data-path-marker on the point it passes
// through (without one, the middle of the block's top edge: the line then runs behind the
// block, so give it a background) and data-path-ring on the element it ends around (in the
// last stop). The figures you can tune are the constants below; the look is in the CSS.
(() => {
const SELECTOR = '.kodza-scroll-path';
const READ = 0.6; // the reading point, down the screen: the line is drawn up to it
const GLIDE = 0.12; // how much of the way the ink catches up each frame (1 = sticks to the scroll)
const SPEED = 1600; // px of line a second, at most: a flick of the wheel is drawn, not jumped
const CURVE = 0.5; // how round the swing between two stops is (share of the gap they leave)
const RING_GAP = 8; // px between the button and its ring
const RING_TIME = 900; // ms to draw the ring once the line reaches the button
const SAMPLE = 6; // px of line between two measured points
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// The site's pace, --kz-motion-scale (set on :root, read on the instance): every duration of the
// script times it, and 0 moves nothing, like reduced motion
const paceOf = (el) => {
const pace = parseFloat(getComputedStyle(el).getPropertyValue('--kz-motion-scale'));
return pace >= 0 ? pace : 1;
};
// One instance. Everything it starts is tied to `signal`, so kodza:destroy stops it all
function mount(root, signal) {
const pace = paceOf(root);
// The still path: the whole line is drawn, every marker reached, the steps simply there.
// The pace slows the pen and the ring (2 = half the speed)
const still = reduceMotion || !pace;
const stops = [...root.querySelectorAll('[data-path-stop]')];
const svg = document.createElementNS(NS, 'svg');
svg.setAttribute('class', 'kodza-scroll-path__svg');
svg.setAttribute('aria-hidden', 'true');
svg.setAttribute('focusable', 'false');
const draw = (tag, className) => {
const el = svg.appendChild(document.createElementNS(NS, tag));
el.setAttribute('class', className);
return el;
};
const track = draw('path', 'kodza-scroll-path__track');
const ink = draw('path', 'kodza-scroll-path__ink');
const pen = draw('circle', 'kodza-scroll-path__pen');
pen.setAttribute('r', '4.5');
root.prepend(svg);
let route = null;
let shown = 0;
let ring = still ? 1 : 0;
let frame = 0;
let last = 0;
// The reading point in the root's own pixels (a parent scaled by transform does not shift it).
// Near the end of the page it slides to the bottom of the screen, so the last steps of a page
// that cannot scroll them up to it are drawn too
const reading = () => {
const box = root.getBoundingClientRect();
const left = document.documentElement.scrollHeight - scrollY - innerHeight;
const end = Math.max(0, 1 - left / (innerHeight * (1 - READ)));
return (innerHeight * (READ + (1 - READ) * end) - box.top) / (box.width / root.offsetWidth || 1);
};
const update = (now = performance.now()) => {
frame = 0;
if (signal.aborted || !route) return;
const value = still ? route.line : route.lengthAt(reading());
const dt = last ? Math.min(64, now - last) : 16;
// The pen glides to the reading point, never faster than SPEED
shown = still ? value : penStep(shown, value, dt, SPEED / pace, GLIDE);
// The ring is played in time once the line reaches the button, and undone (a little faster)
// when the reading point goes back above it
const want = route.ring && shown >= route.line - 0.5 ? 1 : 0;
if (!still && ring !== want) {
const step = dt / (RING_TIME * pace);
ring = want ? Math.min(1, ring + step) : Math.max(0, ring - step * 1.5);
}
const drawn = shown + inOut(ring) * route.ring;
inkTo(ink, drawn, route.total);
const tip = ink.getPointAtLength(drawn);
pen.setAttribute('cx', tip.x.toFixed(1));
pen.setAttribute('cy', tip.y.toFixed(1));
pen.classList.toggle('is-away', still || drawn < 1 || ring === 1);
const done = ring === 1 || (!route.ring && shown >= route.line - 0.5);
stops.forEach((stop, i) => stop.classList.toggle('is-reached', i === stops.length - 1 && route.ring ? done : shown >= Math.max(route.marks[i] - 1, 0.5)));
root.classList.toggle('is-complete', done);
const moving = !still && (shown !== value || ring !== want);
last = moving ? now : 0;
if (moving) frame = requestAnimationFrame(update);
};
const schedule = () => (frame ||= requestAnimationFrame(update));
// Measured again whenever a step changes size: the drawn line snaps to its new length
const remeasure = () => {
route = measure(root, stops, svg, track, ink);
if (route && !still) shown = route.lengthAt(reading());
schedule();
};
const ro = new ResizeObserver(remeasure);
[root, ...stops, ...root.querySelectorAll('[data-path-ring]')].forEach((el) => ro.observe(el));
signal.addEventListener('abort', () => {
ro.disconnect();
cancelAnimationFrame(frame);
});
if (!still && 'IntersectionObserver' in window) {
const io = new IntersectionObserver((entries) => entries.forEach((entry) => {
if (!entry.isIntersecting) return;
entry.target.classList.add('is-seen');
io.unobserve(entry.target);
}), { rootMargin: '0px 0px -10% 0px' });
stops.forEach((stop) => io.observe(stop));
signal.addEventListener('abort', () => io.disconnect());
window.addEventListener('scroll', schedule, { passive: true, signal });
} else stops.forEach((stop) => stop.classList.add('is-seen'));
window.addEventListener('resize', schedule, { signal });
route = measure(root, stops, svg, track, ink);
if (route) shown = still ? route.line : route.lengthAt(reading());
update();
}
// The route engine: where each stop is, the line through them, and how far along the line a
// given height is (the line only ever goes down, so a height gives one length)
function measure(root, stops, svg, track, ink) {
const local = boxesIn(root);
svg.setAttribute('width', root.offsetWidth);
svg.setAttribute('height', root.offsetHeight);
if (!stops.length) return null;
const ringEl = stops[stops.length - 1].querySelector('[data-path-ring]') || (stops[stops.length - 1].matches('[data-path-ring]') ? stops[stops.length - 1] : null);
let d = '';
let ringD = '';
let from = null;
const anchors = stops.map((stop, i) => {
const s = local(stop);
const marker = stop.querySelector('[data-path-marker]');
let point;
if (i === stops.length - 1 && ringEl) {
// The ring: the outline of the button, RING_GAP out, with its own corners (read, not assumed)
const r = local(ringEl);
const [L, T, R, B] = [r.x - RING_GAP, r.y - RING_GAP, r.x + r.w + RING_GAP, r.y + r.h + RING_GAP];
const radius = parseFloat(getComputedStyle(ringEl).borderTopLeftRadius) || 0;
const c = radius ? Math.min((B - T) / 2, (R - L) / 2, radius + RING_GAP) : 0;
const x = (L + R) / 2;
point = { x, y: T, top: T };
ringD = ` H${R - c} A${c},${c} 0 0 1 ${R},${T + c} V${B - c} A${c},${c} 0 0 1 ${R - c},${B}`
+ ` H${L + c} A${c},${c} 0 0 1 ${L},${B - c} V${T + c} A${c},${c} 0 0 1 ${L + c},${T} H${x}`;
} else if (marker) {
const m = local(marker);
point = { x: m.x + m.w / 2, y: m.y + m.h / 2, top: Math.min(s.y, m.y + m.h / 2) };
} else point = { x: s.x + s.w / 2, y: s.y, top: s.y };
if (!from) d = `M${point.x},${point.y}`;
else {
// An S with upright ends, across the gap the two stops leave
const k = Math.max(0, point.top - from.y) * CURVE;
d += ` C${from.x},${from.y + k} ${point.x},${point.top - k} ${point.x},${point.top}`;
if (point.y > point.top) d += ` V${point.y}`;
}
// Down beside the stop, to its bottom edge, where the next swing starts
if (i < stops.length - 1) {
const bottom = Math.max(point.y, s.y + s.h);
d += ` V${bottom}`;
from = { x: point.x, y: bottom };
}
return point;
});
ink.setAttribute('d', d);
const line = ink.getTotalLength();
track.setAttribute('d', d + ringD);
ink.setAttribute('d', d + ringD);
const total = ink.getTotalLength();
// The height of the line every SAMPLE px, kept rising
const count = Math.max(1, Math.ceil(line / SAMPLE));
const ys = [];
for (let i = 0; i <= count; i += 1) ys.push(Math.max(ys[i - 1] ?? -Infinity, ink.getPointAtLength((line * i) / count).y));
const lengthAt = (y) => {
if (y <= ys[0]) return 0;
if (y >= ys[count]) return line;
let lo = 0;
let hi = count;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (ys[mid] <= y) lo = mid;
else hi = mid;
}
const span = ys[hi] - ys[lo];
return ((lo + (span ? (y - ys[lo]) / span : 0)) * line) / count;
};
return { line, total, ring: total - line, lengthAt, marks: anchors.map((point) => lengthAt(point.y)) };
}
// ---------- Pen engine: the same block in Scroll Path, Path Draw and Annotation Arrow ----------
// Boxes measured in a root's own pixels, a line inked by its length (stroke-dasharray: the `d`
// of a path is never animated, Safari would not play it) and a pen that moves along it at a
// steady speed. Nothing in it knows the component around it
const NS = 'http://www.w3.org/2000/svg';
const inOut = (t) => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);
// Where an element sits in `root`, in the root's own pixels: a parent scaled by transform (a
// thumbnail, a theme) does not shift the drawing
const boxesIn = (root) => {
const box = root.getBoundingClientRect();
const scale = box.width / root.offsetWidth || 1;
return (el) => {
const r = el.getBoundingClientRect();
return { x: (r.left - box.left) / scale, y: (r.top - box.top) / scale, w: r.width / scale, h: r.height / scale };
};
};
// Inks a shape up to `length` px along its outline (`total` px long)
const inkTo = (el, length, total) => {
el.style.strokeDasharray = `${length.toFixed(1)} ${Math.ceil(total + 2)}`;
el.style.visibility = length > 0.5 ? '' : 'hidden';
};
// One frame of the pen (`dt` ms): from `shown` towards `target`, at most `speed` px a second,
// a little faster when it lifts the ink off. `glide` eases it in (1: a steady speed)
const penStep = (shown, target, dt, speed, glide = 1) => {
const cap = ((speed * dt) / 1000) * (target < shown ? 1.5 : 1);
const next = shown + Math.sign(target - shown) * Math.min(Math.abs(target - shown) * glide, cap);
return Math.abs(target - next) < 0.5 ? target : next;
};
// ---------- End of the pen engine ----------
// Kodza lifecycle, the same in every component: `kodza:init` starts the instances inside
// event.detail.root (the whole page by default), `kodza:destroy` stops them and puts their
// markup back. For themes and apps that swap content without a page load. Each running root
// keeps its markup as written under KODZA, shared by every Kodza script on the page: a Kodza
// component inside another one is saved as written, not half-started, and stopped with it. Two
// components on one root (a reveal on a stat card) both start, and stop together.
const KODZA = Symbol.for('kodza');
const RUNNING = Symbol.for(`kodza ${SELECTOR}`); // a root this component runs on, for every copy of this script
const instances = new WeakMap();
const find = (scope = document) => [scope, ...scope.querySelectorAll(SELECTOR)].filter((el) => el.matches?.(SELECTOR));
const attrsOf = (el) => [...el.attributes].map((a) => [a.name, a.value]);
const put = (el, saved) => {
[...el.attributes].forEach((a) => el.removeAttribute(a.name));
saved.attrs.forEach(([name, value]) => el.setAttribute(name, value));
el.innerHTML = saved.html;
};
// The markup inside a root as written: Kodza components already running in it give back their own
const written = (root) => {
const all = [...root.querySelectorAll('*')];
if (!all.some((el) => el[KODZA])) return root.innerHTML;
const copy = root.cloneNode(true);
const twins = copy.querySelectorAll('*');
let last = null;
all.forEach((el, i) => {
if (!el[KODZA] || last?.contains(el)) return;
last = el;
put(twins[i], el[KODZA]);
});
return copy.innerHTML;
};
const init = (scope) => find(scope).forEach((root) => {
// Already running, maybe from another copy of this script. Marked is-ready with no Kodza component
// running on it, a root is a copy of a started one (a cloned slide): it is left as it is
if (root[RUNNING] || (root.classList.contains('is-ready') && !root[KODZA])) return;
// Rendered by React (data-kodza-manual): React starts it, with an init aimed at that markup once
// it has hydrated it. Any earlier start would change markup React has not taken over yet
const owner = root.closest('[data-kodza-manual]');
if (owner && !(scope && owner.contains(scope))) return;
const controller = new AbortController();
// Another Kodza component already running on this root: its markup as written is kept, and the
// two stop together
const before = root[KODZA];
const saved = { html: before ? before.html : written(root), attrs: before ? before.attrs : attrsOf(root), stop: () => destroy(root) };
if (before) before.above = saved;
instances.set(root, { controller, saved, before });
root[KODZA] = saved;
root[RUNNING] = true;
mount(root, controller.signal);
root.classList.add('is-ready');
});
const destroy = (scope) => find(scope).forEach((root) => {
const instance = instances.get(root);
if (!instance) return;
instances.delete(root);
instance.saved.above?.stop(); // a Kodza component started after it on the same root stops first
// The Kodza components running inside it stop first: once its markup is put back, their own
// script could not find them any more
root.querySelectorAll('*').forEach((el) => el[KODZA]?.stop());
instance.controller.abort();
delete root[RUNNING];
// Started on top of another one: that one stops next, and puts the markup back
if (instance.before) {
delete instance.before.above;
root[KODZA] = instance.before;
return instance.before.stop();
}
delete root[KODZA];
put(root, instance.saved);
});
document.addEventListener('kodza:init', (event) => init(event.detail?.root));
document.addEventListener('kodza:destroy', (event) => destroy(event.detail?.root));
init();
// Loaded in <head>, the script runs before the markup exists: once more when it is parsed
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => init(), { once: true });
})();
'use client';
// Kodza — Scroll Path, for React. From https://kodza.dev/components/scroll-path/
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Next to this file: scroll-path.css and scroll-path.js, the component's CSS and JS from Kodza, as they are.
// Yours to edit: the markup is plain JSX below.
// The script changes this markup once React has rendered it (it adds layers, splits text, moves
// nodes), so React must not patch it afterwards. When a prop changes, the key gives React a fresh
// element: kodza:destroy stops the script on the old one, kodza:init starts it on the new one.
// data-kodza-manual keeps the script from starting it on its own: the effect below does, once
// React has hydrated the markup (on a page that streams in, the script can load first).
// Built from your data (tabs, rows, options)? Put what the markup is made of in the key, as plain
// values (ids, text, numbers): a change starts the script again on fresh markup. What the visitor
// changes (the open tab, the sort, what they type) is the script's: keep it out of the key, follow
// it with onClick or onInput on the root, and change it from your code as a visitor would (click
// the tab; set the field's value, then dispatch a bubbling input event).
import { useRef, useEffect, type ComponentPropsWithoutRef, type ComponentRef, type CSSProperties } from 'react';
import './scroll-path.css';
export type ScrollPathProps = Omit<ComponentPropsWithoutRef<'div'>, "children" | "vars"> & {
/** Settings, as CSS variables on the root */
vars?: {
/** default: #0a0a0a */
"--kz-scroll-path-fg"?: string | number;
/** default: rgba(10, 10, 10, 0.62) */
"--kz-scroll-path-muted"?: string | number;
/** the page behind the markers and around the pen — default: #f2f2f0 */
"--kz-scroll-path-bg"?: string | number;
/** the line, the pen and the markers it reached — default: #0a0a0a */
"--kz-scroll-path-ink"?: string | number;
/** default: #ffffff */
"--kz-scroll-path-on-ink"?: string | number;
/** the dotted route, before the ink — default: rgba(10, 10, 10, 0.26) */
"--kz-scroll-path-track"?: string | number;
/** a marker not reached yet — default: rgba(10, 10, 10, 0.16) */
"--kz-scroll-path-rim"?: string | number;
/** thickness of the line — default: 2px */
"--kz-scroll-path-width"?: string | number;
/** default: 44px */
"--kz-scroll-path-marker"?: string | number;
/** width of a step — default: 440px */
"--kz-scroll-path-column"?: string | number;
/** between two steps: the swings live there — default: 200px */
"--kz-scroll-path-gap"?: string | number;
/** default: cubic-bezier(0.22, 1, 0.36, 1) */
"--kz-scroll-path-ease"?: string | number;
};
className?: string;
};
export function ScrollPath({ vars, className, style, ...rest }: ScrollPathProps) {
const ref = useRef<ComponentRef<'div'>>(null);
const classes = ["kodza-scroll-path", className].filter(Boolean).join(' ');
// Everything the markup is made of: when it changes, React gets a fresh element (see the top)
const key = JSON.stringify([classes, vars, style, rest]);
// Starts the script on this element once React has rendered it, stops it when it goes
useEffect(() => {
const root = ref.current;
let live = true;
// @ts-ignore: a plain script, no types to find
import('./scroll-path.js').then(() => {
if (live) document.dispatchEvent(new CustomEvent('kodza:init', { detail: { root } }));
});
return () => {
live = false;
document.dispatchEvent(new CustomEvent('kodza:destroy', { detail: { root } }));
};
}, [key]);
return (
<div key={key} {...rest} className={classes} data-kodza-manual style={{ ...vars, ...style } as CSSProperties} ref={ref}>
<ol className="kodza-scroll-path__list">
<li className="kodza-scroll-path__stop" data-path-stop="">
<span className="kodza-scroll-path__marker" data-path-marker="" aria-hidden="true">01</span>
<div className="kodza-scroll-path__body">
<h3 className="kodza-scroll-path__title">Find it</h3>
<p className="kodza-scroll-path__text">Search the catalogue, or ask your AI. It gets the exact files, not a guess at them.</p>
</div>
</li>
<li className="kodza-scroll-path__stop" data-path-stop="">
<span className="kodza-scroll-path__marker" data-path-marker="" aria-hidden="true">02</span>
<div className="kodza-scroll-path__body">
<h3 className="kodza-scroll-path__title">Copy it</h3>
<p className="kodza-scroll-path__text">HTML, CSS, a few lines of JS. No package, no build step, no account to create.</p>
</div>
</li>
<li className="kodza-scroll-path__stop" data-path-stop="">
<span className="kodza-scroll-path__marker" data-path-marker="" aria-hidden="true">03</span>
<div className="kodza-scroll-path__body">
<h3 className="kodza-scroll-path__title">Paste it</h3>
<p className="kodza-scroll-path__text">WordPress, Webflow, React or a plain page. It keeps to its own class and leaves yours alone.</p>
</div>
</li>
<li className="kodza-scroll-path__stop" data-path-stop="">
<span className="kodza-scroll-path__marker" data-path-marker="" aria-hidden="true">04</span>
<div className="kodza-scroll-path__body">
<h3 className="kodza-scroll-path__title">Tune it</h3>
<p className="kodza-scroll-path__text">Colours, sizes and timings are variables at the top of the file. Change three, keep the rest.</p>
</div>
</li>
<li className="kodza-scroll-path__stop" data-path-stop="">
<span className="kodza-scroll-path__marker" data-path-marker="" aria-hidden="true">05</span>
<div className="kodza-scroll-path__body">
<h3 className="kodza-scroll-path__title">Ship it</h3>
<p className="kodza-scroll-path__text">Tested in three themes before you saw it. It works on more than my machine.</p>
</div>
</li>
</ol>
<div className="kodza-scroll-path__end" data-path-stop="">
<a className="kodza-scroll-path__cta" href="#" data-path-ring="">Browse components</a>
<p className="kodza-scroll-path__note">Free. No sign-up.</p>
</div>
</div>
);
}
Checked
Robots poked at it before you did. Every component goes through the same checks, and again each time it changes.