Introduction
This sketch reads as an org chart you walk through rather than glance at: a CEO node, three manager branches, and a base layer of developers and ICs, laid out left to right on a canvas that is roughly 2000px wide. The trick is that the page never actually scrolls sideways — a single pinned section converts the visitor’s ordinary vertical scroll into a horizontal scrub of that canvas, so the chart advances one wheel-tick at a time instead of jumping.
Four responsibilities stay separate the whole way through: the scroll rig only knows how to turn vertical scroll into a horizontal transform, the SVG threads only know how to draw themselves in as they enter view, the cards only know how to pop in and hold a claymorphic shadow pair, and a small pulse layer only knows how to loop along a thread once it is fully drawn. None of the four reach into each other’s internals.
How to pin a section and drive its horizontal position from vertical scroll with GSAP’s containerAnimation option — how to make an SVG path draw itself in with stroke-dasharray instead of a canvas particle trick — how to build a dark claymorphic card with two opposing box-shadows instead of the usual light-gray neumorphism default — and how to send a small circle looping along a finished path with MotionPathPlugin.
Step 1 — Pin the Wrapper, Scrub the Canvas
The wrapper is a full-viewport section with overflow hidden; inside it sits a much wider canvas holding every node and connector. ScrollTrigger pins the wrapper in place and, as the page keeps scrolling underneath it, tweens the canvas’s x position from 0 to the negative of however much extra width it has beyond the viewport. The result feels like scrolling sideways, but it is really just one long vertical scroll being read as a horizontal value.
const canvas = document.querySelector(".canvas");
let scrollMax = canvas.scrollWidth - window.innerWidth + 200;
const horizontalTween = gsap.to(canvas, {
x: -scrollMax,
ease: "none",
scrollTrigger: {
trigger: ".scroll-wrapper",
pin: true,
scrub: 1,
end: () => "+=" + scrollMax,
onUpdate: (self) => {
gsap.set(".progress-fill", { scaleX: self.progress });
}
}
});That same horizontalTween is passed into every other ScrollTrigger on the page as containerAnimation, which is what lets nodes and lines that live far to the right still animate in relative to when they scroll into the (still notionally vertical) viewport.
Step 2 — stroke-dasharray as a Progress Bar
Each connector between a role and its reports is a plain SVG path. Setting stroke-dasharray to the path’s own total length and stroke-dashoffset to that same value hides the whole line; animating dashoffset down to zero reveals it stroke by stroke. Because the tween is scrubbed against containerAnimation, the line only draws as far as the visitor has actually scrolled into it — scroll back and it retreats.
const paths = gsap.utils.toArray(".line-path");
paths.forEach((path) => {
const length = path.getTotalLength();
gsap.set(path, { strokeDasharray: length, strokeDashoffset: length });
gsap.to(path, {
strokeDashoffset: 0,
ease: "power2.inOut",
scrollTrigger: {
trigger: path,
containerAnimation: horizontalTween,
start: "left right-=180",
end: "right center",
scrub: true
}
});
});The line doesn’t know it’s connecting a manager to a developer — it just knows how far along itself it’s allowed to be, given how far the visitor has scrolled.
Step 3 — Two Shadows Instead of One
Every node — dot, plus-button, or card — shares one .clay base class carrying a pair of opposing box-shadows: a near-black cast shadow on one corner and a lighter raised tone on the other, both derived from the same background color. On a dark forest-ink background that reads as soft, pressed clay instead of the light-gray neumorphism this style usually defaults to. Cards additionally get a photo, a level badge, an icon, and two stat numbers pulled straight from the org data.
.clay {
position: absolute;
transform: translate(-50%, -50%);
background: var(--raise);
box-shadow:
10px 10px 22px var(--shadow-dark),
-8px -8px 18px var(--shadow-light);
}
.card.is-trigger,
.card.is-success {
border-color: var(--brass);
}The canvas itself has no notion of levels — it only positions absolutely-placed elements at fixed x/y coordinates. The hierarchy reads correctly because the CEO card sits alone on the center line, the three manager cards are spread vertically at a shared x, and every connector routes back to that shared center line before continuing to the base layer, the same way a hand-drawn org chart would.
Step 4 — Reveal on Entry, Then Loop a Pulse
Every dot, button, and card carries a .gs-reveal class and pops in from scale 0 with a back-out ease as it scrolls into range. Once a connector finishes drawing, its scrollTrigger’s onEnter callback releases a small circle onto MotionPathPlugin, which loops it along that exact path indefinitely — a lightweight way to suggest something is actively flowing through the structure without re-triggering the draw animation itself.
gsap.utils.toArray(".gs-reveal").forEach((el) => {
gsap.from(el, {
scale: 0,
opacity: 0,
duration: reduceMotion ? 0.01 : 0.8,
ease: "back.out(1.5)",
scrollTrigger: {
trigger: el,
containerAnimation: horizontalTween,
start: "left right-=140",
toggleActions: "play none none reverse"
}
});
});
// once a thread is fully drawn, release a looping pulse onto it
onEnter: () => {
gsap.set(pulse, { opacity: 1 });
gsap.to(pulse, {
motionPath: { path, align: path, alignOrigin: [0.5, 0.5] },
duration: 2.4,
ease: "sine.inOut",
repeat: -1
});
}Tuning Reference
| Property | Example | Effect |
|---|---|---|
| canvas width / height | 2000px / 900px | Total scrub distance and vertical room for branch spread |
| branch y-offsets | 220 / 450 / 680 | How far the three manager cards spread above and below the center line |
| scrub value | 1 | How tightly the horizontal position and line-draw follow the scrollbar; higher adds lag |
| reveal start offset | left right-=140 | How early a node pops in before it reaches the viewport edge |
| pulse duration | 2.4s, repeat -1 | Speed and looping of the traveling dot once a thread finishes drawing |
| prefers-reduced-motion | skips pulse + shortens reveal duration | Keeps the chart usable without relying on motion to convey structure |
Full Source Code
The complete sketch is a single self-contained HTML document — the pinned scrub rig, the self-drawing SVG threads, the claymorphic card system, and the reveal/pulse choreography, with no build step or external diagramming library.
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/hierarchy-flow.html