Introduction
This showcase has no photographs, no image files, and no animation library. There is a brass rail across the top of the page and nine garments hanging from it. Point at one and it swings forward and grows under a soft spotlight while its neighbours slide out of the way and dim. Move the pointer off and the piece swings back to its hook and everything settles into its original place.
The effect rests on four small ideas: a rail that sizes its pieces from the width of its own container, garments built as strings of SVG paths, a hover state that is just one class on one piece plus one direction number on every piece, and a pendulum animation that plays only when a piece becomes active. One short script wires the pointer to that state.
How to size children from a container with cqw units instead of the viewport — how to draw clothing with a shared shading gradient — how to make hover move the siblings as well as the hovered item — how to run an idle sway and a one-off settle swing on the same element — and how to make the same interaction work for mouse, touch, and keyboard.
Step 1 — Size Pieces from the Container, Not the Screen
The first version sized each garment with vw units. That works on a full browser window and fails the moment the page sits inside a narrower panel, because the viewport is wide while the space that holds the rail is not. The last pieces were cut off. The fix is to make the scroller a size container and measure everything against it with cqw, where 1cqw is one percent of the container width.
.scroller {
container-type: inline-size;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
}
.scroller::-webkit-scrollbar { display: none; }
.rail {
--gap: 8px;
--pw: clamp(54px, calc((100cqw - 8 * var(--gap) - 6px) / 9.9), 112px);
position: relative;
min-width: calc(9 * var(--pw) + 8 * var(--gap) + var(--pw) * 0.8);
padding-inline: calc(var(--pw) * 0.4);
}The formula reads: take the container width, subtract the eight gaps between nine pieces, and divide by 9.9, which is nine pieces plus the 0.8 of a piece used as edge padding, with a little spare. The clamp keeps a piece between 54 and 112 pixels. Below 54 the rail stops shrinking and the scroller takes over, so a phone scrolls sideways instead of showing nine unreadable slivers.
A hovered piece grows past the edge of the rail, and anything that overflows a scroller can make its scrollbar appear as a thin line. Hiding the bar and adding edge padding of 0.4 of a piece on each side removes the line while leaving touch scrolling intact.
The brass bar itself is a single absolutely positioned div with a vertical gradient. Its two end caps are the ::before and ::after pseudo-elements, each a small rounded rectangle with a horizontal gradient, so the rail needs no extra markup.
Step 2 — One Path per Silhouette, One Gradient for Shape
Every garment is an inline SVG with the same 140 by 224 viewBox, built by a function that returns a string. The silhouettes are three path strings: a tee, a long-sleeve crew, and a hoodie body. The sweater reuses the crew path and the jersey reuses the tee path, so six different garments come from three outlines plus details.
var D = {
tee: 'M56 34 Q70 48 84 34 L112 46 L128 82 L108 90 L98 76 L98 202 Q70 208 42 202 L42 76 L32 90 L12 82 L28 46 Z',
crew: 'M56 34 Q70 48 84 34 L102 42 Q112 48 114 70 L120 192 L104 196 L98 106 L98 204 Q70 210 42 204 L42 106 L36 196 L20 192 L26 70 Q28 48 38 42 Z'
};
function body(d, c, extra) {
return '<path d="' + d + '" fill="' + c + '"/>' +
(extra || '') +
'<path d="' + d + '" fill="url(#shade)"/>' +
'<path d="' + d + '" fill="none" class="g-rim"/>';
}The body function draws the same outline three times. The first pass is the flat colour. The second pass is a shared horizontal gradient that darkens both edges and adds a faint highlight near the middle, which makes a flat shape read as cloth with volume. The third pass is a thin outline in a rim colour taken from a CSS variable, so a black jersey still shows its edge against a dark wall.
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<linearGradient id="shade" x1="0" x2="1" y1="0" y2="0">
<stop offset="0" stop-color="#000" stop-opacity=".30"/>
<stop offset=".22" stop-color="#000" stop-opacity=".02"/>
<stop offset=".55" stop-color="#fff" stop-opacity=".07"/>
<stop offset=".8" stop-color="#000" stop-opacity=".06"/>
<stop offset="1" stop-color="#000" stop-opacity=".34"/>
</linearGradient>
</defs>
</svg>The gradient lives once in a zero-size SVG at the top of the page and every garment refers to it by id. The sweater adds a small repeating knit pattern the same way. Each garment then gets a walnut peg and a wire hanger drawn first, so the cloth sits in front of them, and details such as a collar rib, a seam, a pocket, or a drawstring are extra paths layered on top.
The page is named Dress Showcase while the sample pieces are tees, hoodies, and a jersey. To show dresses, add new path strings to the D object — an A-line, a wrap, a maxi — and add a matching branch in the garment function. The rail, the hover, and the animation do not change.
Step 3 — One Class and One Direction Number
Hover does two jobs: it brings one piece forward and it clears room for it. The first job is a class. The second is a custom property named --dir that is set on every piece. A piece to the left of the active one gets -1, a piece to the right gets 1, and the active piece gets 0. The CSS turns that number into a horizontal shift.
.piece {
transform-origin: 50% 0;
transform: translateX(calc(var(--dir, 0) * var(--pw) * 0.34));
transition: transform 0.8s cubic-bezier(0.22, 1.12, 0.36, 1),
opacity 0.45s ease, filter 0.5s ease;
}
.piece.is-active {
z-index: 5;
transform: translateY(14px) scale(1.5);
filter: drop-shadow(0 20px 18px var(--shadow));
}
.rail.has-active .piece:not(.is-active) { opacity: 0.45; }The transform origin is the top centre of the piece, which is where the hook sits. Scaling from there makes the garment grow downward the way real cloth would hang, instead of ballooning around its middle. The easing curve has a control value above 1, so the transform overshoots slightly before settling, which gives the lift a small springy feel.
The push distance is 0.34 of a piece width. A piece scaled to 1.5 grows by a quarter of its width on each side, so neighbours that move by a third of a width clear it with a little room left over. Everything on one side moves by the same amount, so the spacing between the pushed pieces stays even. A soft radial glow behind the active piece follows it by reading the piece position in JavaScript and writing it to a --gx variable.
Returning to rest needs no code at all. Remove the class, reset --dir to 0, and the same transition that carried each piece out carries it home.
Step 4 — Two Animations on a Nested Element
Each piece is a button that contains a span, and the span holds the SVG. The button handles the hover transform. The span handles rotation. Keeping them on separate elements matters because two animations that both write transform on one element would overwrite each other.
.sway {
transform-origin: 50% 0;
animation: sway 7s ease-in-out infinite;
animation-delay: calc(var(--i) * -0.9s);
}
.piece.is-active .sway {
animation: settle 1.1s cubic-bezier(0.2, 0.8, 0.3, 1) both;
}
@keyframes sway {
0%, 100% { transform: rotate(0deg); }
30% { transform: rotate(1.3deg); }
70% { transform: rotate(-1.3deg); }
}
@keyframes settle {
0% { transform: rotate(-7deg); }
35% { transform: rotate(4deg); }
65% { transform: rotate(-1.6deg); }
100% { transform: rotate(0deg); }
}The idle sway is a barely visible rotation of 1.3 degrees either way over seven seconds. Each piece has an index stored in --i, and a negative animation delay of 0.9 seconds times that index starts every piece at a different point in the cycle, so the rail never sways in unison. A negative delay is used on purpose: it makes the animation begin partway through, with no waiting at page load.
When a piece becomes active the sway is replaced by settle, a four-step pendulum that starts tilted, overshoots to the other side, and damps to zero. It plays once and holds its final frame. Because sway starts and ends at zero rotation, the piece also returns to its idle motion without a visible jump.
Step 5 — One setActive Function, Three Ways In
All state lives in one number: the index of the active piece, or -1 for none. A single function applies it by toggling the class, setting --dir on every piece, and moving the glow.
function setActive(i) {
if (i === active) return;
active = i;
els.forEach(function (el, k) {
el.classList.toggle('is-active', k === i);
el.style.setProperty('--dir', i < 0 ? 0 : Math.sign(k - i));
});
railEl.classList.toggle('has-active', i >= 0);
if (i >= 0) {
var el = els[i];
railEl.style.setProperty('--gx', (el.offsetLeft + el.offsetWidth / 2) + 'px');
}
}Math.sign turns the difference between a piece index and the active index into -1, 0, or 1, which is exactly the direction the CSS needs. The early return when the index has not changed stops pointer events that fire repeatedly from restarting the settle animation.
b.addEventListener('pointerenter', function (e) {
if (e.pointerType === 'mouse' || e.pointerType === 'pen') setActive(i);
});
b.addEventListener('pointerleave', function (e) {
if ((e.pointerType === 'mouse' || e.pointerType === 'pen') && active === i) setActive(-1);
});
b.addEventListener('click', function (e) {
if (e.pointerType === 'mouse' || e.pointerType === 'pen') return;
setActive(active === i ? -1 : i);
});
b.addEventListener('focus', function () {
if (b.matches(':focus-visible')) setActive(i);
});
b.addEventListener('blur', function () {
if (active === i) setActive(-1);
});
document.addEventListener('pointerdown', function (e) {
if (e.pointerType === 'touch' && !e.target.closest('.piece')) setActive(-1);
});A mouse activates on enter and releases on leave. A finger has no hover, so touch toggles on tap and a tap anywhere outside a piece releases it. Keyboard focus activates only when :focus-visible matches, which avoids lifting a piece after a mouse click while still showing it to someone tabbing through the rail. The leave handler checks that the leaving piece is still the active one, so moving quickly between neighbours cannot cancel the piece that just took over.
Each piece is a real button with an aria-label that carries its name and price, because the SVG inside is hidden from assistive technology. The decorative defs SVG is hidden too.
Step 6 — Every Colour Is a Variable
The page is dark by default and has a light variant. Colours live in variables on :root: the wall, two text colours, an accent, two brass tones, the walnut of the pegs, the hanger wire, the rim colour, the glow, and the drop shadow. The light theme is applied by a prefers-color-scheme media query and again by a data-theme attribute, so an explicit choice always beats the system setting. Garment colours are the one thing that does not change, because a green hoodie should stay green in both themes.
@media (prefers-reduced-motion: reduce) {
.sway,
.piece.is-active .sway { animation: none; }
.piece, .glow { transition-duration: 0.01s; }
}Anyone who asks their system for reduced motion gets no sway, no settle swing, and near-instant transitions. The interaction still works because the piece still moves forward and the neighbours still step aside; only the movement between those states is removed.
Tuning Reference
| Property | Example | Effect |
|---|---|---|
| --gap | 8px | Space between pieces on the rail |
| --pw divisor | 9.9 | Nine pieces plus edge padding; lower makes pieces larger |
| --pw clamp | 54px – 112px | Smallest and largest piece width before the rail scrolls |
| edge padding | 0.4 × --pw | Room at both ends so a grown piece never overflows |
| push distance | 0.34 × --pw | How far neighbours move away from the active piece |
| active scale | 1.5 | How large the forward piece grows |
| neighbour opacity | 0.45 | How far the other pieces dim while one is active |
| sway | 7s, ±1.3deg | Idle motion of every piece |
| settle | 1.1s | Length of the pendulum swing when a piece comes forward |
Full Source Code
The whole project is a single self-contained HTML file — markup, styles, and script, with no build step. The only external request is the Google Fonts stylesheet for the three typefaces.
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/dress-showcase.html