All components

Camille Mormal Slider

GSAP animation component · Published 2026-07-21 · Updated 2026-08-16 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

A four-plate portrait slider: each photograph is framed as a print on a bone-paper wall (crop marks and all) rather than used as a full-bleed background, with the whole naming rail — title, sampled ground colour, counter, progress and thumbnails — kept beside the picture. Clicking either half of the plate, a thumbnail, an arrow key or one of the rotating '+' marks swaps plates with a GSAP clip-path wipe while the outgoing photograph pushes sideways. A CustomEase 'hop' curve drives the wipe, the three odometers (title, ground hex, counter), the progress fill and the +/-90 degree mark rotation; the accent colour of the whole page is sampled from the seamless paper of the current plate.

How it's built

Categoryslider
Techgsap
GSAP pluginsCustomEase
Complexitypage
Performance costlight
Mobile-safeyes

slider clip-path gsap customease image-reveal counter editorial click-driven keyboard

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

Framed-Plate Portrait Slider — Clip-Path Wipe + Image Parallax + CustomEase Odometers

Goal

Build a one-screen portrait slider that presents each photograph as a framed plate on a paper wall, not as a full-bleed background. The page is split into a naming rail on the left (series blurb, slide title, sampled ground colour, counter, progress rail, thumbnails) and a stage on the right holding the plate itself, surrounded by printer's crop marks.

Navigation is by clicking either half of the plate, by the two rotating + marks flanking it, by a thumbnail, or by the arrow keys. On each navigation the incoming photograph is revealed by a clip-path wipe growing from the edge you came from, its inner <img> slides in with a parallax offset while the outgoing image pushes off the opposite side. In sync, three vertically sliding odometers (slide title, ground hex, counter), a progress fill, an accent-colour change across the whole page, and the ±90° rotation of the two + marks all move on a shared CustomEase curve called "hop". No scroll, no autoplay.

The decision that makes this component

The obvious build for a photo slider is width:100vw; height:100vh; object-fit:cover with the type floated on top and a scrim to keep it legible. Do not build that here. It is the reason most photo sliders look cheap: a viewport is never the aspect ratio of the picture, so every frame gets cropped differently, a portrait and a landscape in the same deck stop reading as one series, and the type has to fight whatever happens to be under it that second.

Instead:

  • The photograph lives in a plate of fixed ratio (10:7, the ratio of the source files) sized so it always fits one screen. Every frame crops identically, so a set of pictures reads as a set.
  • All type lives beside the plate, never on it. No scrim, no text-shadow, no dark gradient over the artwork. Contrast is solved by layout instead of by veiling the picture.
  • Colour is the only thing that changes per slide. A single --accent, sampled from the actual seamless-paper backdrop of the current photograph, drives the swatch, the ground hex line, the progress fill, the active thumbnail rule and the nav underline. Nothing else in the palette moves. The rest of the page is one warm bone paper and one near-black ink.

Tech

Vanilla HTML/CSS/JS with ES module imports. gsap (npm) plus the single GSAP plugin CustomEase (import { CustomEase } from "gsap/CustomEase"). No other libraries — no ScrollTrigger, no Lenis, no carousel library. Everything runs inside a DOMContentLoaded listener; call gsap.registerPlugin(CustomEase) first.

Layout / HTML

header.masthead                       ← flex row, hairline bottom rule
  a.wordmark            "Camille Mormal"
  nav.masthead-nav
    a[aria-current]     "Work"        (underlined in the accent colour)
    a                   "Studio"
    a                   "Contact"

main.slider                           ← grid: [rail minmax(288px,30%)] [stage 1fr]

  section.meta                        ← the naming rail, hairline right rule
    .meta-head
      p.eyebrow         "Portrait series · 2026"
      p.series          "Four Grounds"
      p.series-note     one or two lines about the shoot

    .meta-plate
      .slide-title                    ← odometer window, exactly 1 line tall
        .slide-title-wrapper          ← translated vertically
          p "Vermilion"  p "Verdigris"  p "Ochre"  p "Amethyst"
      .slide-ground
        span.swatch                   ← 11px square filled with --accent
        .slide-hex                    ← odometer window, exactly 1 line tall
          .slide-hex-wrapper
            p "Ground #B30B15" … one per plate

    .meta-foot
      .counter-row
        .counter-window               ← odometer window, exactly 1 line tall
          .counter
            p "01"  p "02"  p "03"  p "04"
        span.counter-total  "/ 04"
      .rail > span.rail-fill          ← 2px progress bar, scaleX driven
      .thumbs[role=group]
        button.thumb.is-active > img  (plate 1)
        button.thumb            > img (plates 2–4)

  section.stage                       ← grid place-items:center, relative
    .plate                            ← 10:7, the click target
      .slider-images                  ← absolute, overflow hidden (clips the wipe)
        .img > img                    ← ONE slide hard-coded (image #1)
      span.crop-tl / .crop-tr / .crop-bl / .crop-br
    button.step[data-dir=prev] > span.step-mark "+"
    button.step[data-dir=next] > span.step-mark "+"
    p.browse                          ← one-line interaction hint

Notes:

  • Only one .img is present in the HTML. Every subsequent slide is created in JS and appended to .slider-images.
  • The thumbnails are real <button>s in a labelled role="group", each carrying its plate's ground colour in data-accent and a descriptive aria-label.
  • The two + marks are real <button>s with aria-label, 48×48 of hit area, disabled at the ends of the deck — they are the controls, not decoration that happens to sit near one.
  • Names are neutral pigment words (Vermilion / Verdigris / Ochre / Amethyst) — no real brands, no invented sitters.

Styling

  • Fonts: Instrument Serif (display: wordmark, series line, slide title, counter numerals) and Inter (everything else: labels, eyebrows, notes). The hierarchy is carried by size and family, not by weight alone — one 58px serif title against 10px tracked uppercase Inter labels.
  • Palette on :root: --paper:#ebe6dc, --ink:#16130f, --ink-soft:rgba(22,19,15,.58), --ink-faint:rgba(22,19,15,.34), --rule:rgba(22,19,15,.14), --accent:#b30b15 (rewritten per slide by JS). Also --edge, --chrome, --serif, --sans.
  • * { margin:0; padding:0; box-sizing:border-box; user-select:none }. body is a column flexbox at min-height:100dvh with the paper background.
  • .slider: display:grid; grid-template-columns: minmax(288px, 30%) 1fr; flex:1; min-height:0.
  • .meta: column flexbox, justify-content:space-between, border-right:1px solid var(--rule) — the rule, not a background change, is what separates rail from stage.
  • The plate.stage { display:grid; place-items:center; padding:clamp(1.5rem,3.2vw,3.5rem); overflow:hidden }; .plate { position:relative; aspect-ratio:10/7; width:min(100%, calc((100dvh - var(--chrome)) * 1.4286)) } where --chrome: 13rem is the vertical room the masthead and the stage padding take. That one min() is what guarantees the plate keeps its ratio *and* never pushes the page past one screen — no JS sizing.
  • .plate must not clip. overflow:hidden goes on .slider-images instead, because the four crop marks are positioned 9px *outside* the plate's corners and a clip on the plate would eat them.
  • Odometer windows.slide-title, .slide-hex, .counter-window each set their own font-size and line-height and then height: 1.22em / 1.7em / 1.15em — i.e. exactly one line — with overflow:hidden. Their p children use font: inherit and white-space: nowrap.
  • Thumbnailsaspect-ratio:10/7; min-height:46px (56px on mobile) so they stay finger-sized, filter:grayscale(1); opacity:.55 at rest, full colour when active or hovered, with a 2px accent bar sliding in underneath via ::after { transform:scaleX(0→1) }. Desaturating the inactive ones is not decoration: the ground colour *is* the subject of the series, so withholding it is the clearest possible "not this one".
  • Progress.rail is a 2px --rule line; .rail-fill is an absolutely-positioned accent bar with transform-origin:0 50%, tweened to scaleX(current/total).
  • :focus-visible { outline:2px solid var(--accent); outline-offset:3px } — every control is reachable by keyboard and shows it.
  • Responsive @media (max-width:900px): .slider becomes a column flexbox with the stage first (order:1) and the rail second; the plate goes to width:100% at the same 10:7; the two + buttons lose their gutter, so they move onto the plate edges with a 46px translucent paper chip behind them; .browse is dropped. @media (max-width:480px) also drops .series-note and shrinks the masthead. @media (prefers-reduced-motion: reduce) collapses every CSS transition.
  • Shrink the masthead on a phone, and check it with arithmetic. It is the most rigid row on the page — an italic serif wordmark next to three uppercase links tracked out at 0.2em — and space-between cannot save a line that does not fit: it drops the padding and lets the last link run off the screen. At the desktop sizes the row asks for 410px (20 padding + 127 wordmark + 24 gap + 219 nav + 20 padding), which does not fit a 390px phone, never mind a 320px one. Under 480px take the wordmark to 1rem, the links to 0.5625rem and — this is the half people forget — the tracking to 0.1em, because 0.2em on three uppercase words is ~17px of pure letter-spacing. Same three items, ~300px, so 320px clears with 20px to spare. Add flex-wrap: wrap as the net for the font-size control at 160%, where no size keeps three tracked links on one line: the nav drops to a second row instead of being cut.
  • Mobile type overrides target the odometer windows (.slide-title, .counter-window), never the p inside them — see the trap below.

GSAP effect (exhaustive)

CustomEase "hop" (shared by every tween)
gsap.registerPlugin(CustomEase);
CustomEase.create("hop", "M0,0 C0.071,0.505 0.192,0.726 0.318,0.852 0.45,0.984 0.504,1 1,1");

The curve rises steeply and reaches ~1 early (around x≈0.5) then flattens — a fast "snap-then-settle". Every tween in this component uses ease: "hop".

State
const total   = thumbs.length;   // 4, read from the DOM — not a literal
let current   = 1;               // 1-based index of the visible plate
let markRotation = 0;            // accumulated degrees for the two "+" marks

const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
const SLIDE = reduced ? 0.01 : 1.5;   // the three transition tweens
const STEP  = reduced ? 0.01 : 1;     // odometers, rail, mark rotation
Odometer translation — place(duration)

The three odometers are translated by whole lines. The step is read from the clipping window at call time, never hard-coded:

const odometers = [titles, hexes, counter];

function place(duration) {
  odometers.forEach((track) => {
    gsap.to(track, {
      y: -track.parentElement.clientHeight * (current - 1),
      duration, ease: "hop", overwrite: true,
    });
  });
}

Since each window is styled to be exactly one line tall, clientHeight *is* the step. A ResizeObserver on the title window calls place(0) whenever that height changes — a breakpoint, a rotated phone, a font-size control feeding a new :root size — so the odometers re-seat themselves instead of drifting.

Per-slide repaint — paint()
document.documentElement.style.setProperty("--accent", thumbs[current-1].dataset.accent);
// toggle .is-active + aria-current on the thumbnails
gsap.to(railFill, { scaleX: current / total, duration: STEP, ease: "hop" });
// disable the prev button at slide 1 and the next button at the last slide

Rewriting one custom property is the whole colour change: the swatch, the hex line, the rail fill, the active thumbnail bar and the nav underline all reference var(--accent) and all carry a 0.7s CSS transition on the concrete property, so they cross-fade together without a single extra tween.

Slide transition — animateSlide(direction)

direction is "left" (backward) or "right" (forward).

  1. Grab the outgoing slide: the last .img currently inside .slider-images.
  2. Compute the push distance from the plate, not from a constant:

``js const push = Math.min(500, plate.clientWidth * 0.45); `` A fixed ±500px reads as a graceful drift in a 900px plate and as a violent throw in a 340px one on a phone.

  1. Build the incoming slide: create div.img + img with src = img${current}.jpg (current is already the destination index) and copy the matching thumbnail's alt. Pre-offset it with gsap.set(img, { x: direction === "left" ? -push : push }), then append.
  2. Push the outgoing image off: gsap.to(outgoing.querySelector("img"), { x: direction === "left" ? push : -push, duration: SLIDE, ease: "hop" }).
  3. Wipe the incoming slide open from the edge you came from — with the horizontal edges of the polygon pushed 2% *outside* the plate:

``js gsap.fromTo(frame, { clipPath: direction === "left" ? "polygon(0% -2%, 0% -2%, 0% 102%, 0% 102%)" : "polygon(100% -2%, 100% -2%, 100% 102%, 100% 102%)" }, { clipPath: "polygon(0% -2%, 100% -2%, 100% 102%, 0% 102%)", duration: SLIDE, ease: "hop", onComplete: dropSpentFrames }); ` The overshoot clips nothing — the frame has no paint above 0% or below 100% — and it buys the one thing a 100% edge cannot give you: the clip boundary no longer lands on the same fractional pixel row as the plate's own edge. The trap: the frame *underneath* is a GSAP-transformed layer, so the compositor snaps it to whole device pixels while the clipped frame on top is not snapped. Where the plate's height lands on a half pixel — which on a phone it does — one device row at the top of the plate ends up painted by the outgoing photograph: a coloured hairline exactly plateWidth − push long, on the top edge, in the previous slide's ground colour. It looks like a stray border and it is not one; do not chase it with border-top` or a background, and do not "fix" it by rounding the plate size.

  1. Parallax the incoming image to rest: gsap.to(img, { x: 0, duration: SLIDE, ease: "hop" }). The picture drifts into place *while* the mask opens over it — the two move at different rates, which is the parallax.
  2. Prune the stack, twice over. Straight after appending, if .slider-images holds more than total frames, remove the oldest — that is the cap for someone hammering the deck. Then, in the wipe's onComplete, drop everything *below* the frame that just finished and clear its own clipPath and the image's transform: once the wipe is over that frame covers the plate on its own, so the frames underneath are pure liability — composited layers that can peek past the plate's edge, holding decoded images alive. Guard it with if (frame !== sliderImages.lastElementChild) return;: if the visitor clicked again mid-wipe, this callback belongs to a frame that is no longer on top and must not delete the ones still animating — the newer frame's own onComplete will clean up.
  3. Rotate the marks: markRotation += direction === "left" ? -90 : 90; gsap.to(marks, { rotate: markRotation, duration: STEP, ease: "hop" }). A + at 90° reads as ×, so the two marks flip between + and × on every step and keep winding.
Navigation — goTo(index)
function goTo(index) {
  if (index === current || index < 1 || index > total) return;
  const direction = index < current ? "left" : "right";
  current = index;
  animateSlide(direction);
  place(STEP);
  paint();
}

Four entry points, all routed through it:

  • .plate click — compares event.clientX against the plate's own getBoundingClientRect() midpoint, so the split follows the picture, not the window. The listener is on the plate, not on document: clicking the rail, a thumbnail or an arrow button must not also advance the deck.
  • .step buttonsgoTo(current ∓ 1) off data-dir.
  • thumbnailsgoTo(i + 1); a multi-step jump still plays a single wipe, in the direction of the jump.
  • keydown on documentArrowLeft / ArrowRight.

Navigation is clamped: no wrap-around, and the buttons go disabled at the ends.

The trap

Never let a magic number in the CSS have to agree with a magic number in the JS. The naive build of this odometer is height:60px; line-height:60px in the stylesheet and y: -60 * (current - 1) in the script. It works exactly once. Change the title size, cross a breakpoint, ship a font-size control, or let the demo run on a machine where the display font falls back — and the two numbers disagree by a few pixels. The failure is silent and ugly: the window shows a sliver of the next title above or below the current one, forever, and nothing writes to the console.

The fix is to give the height a single owner. The CSS sets the type on the window and gives it height: 1.22em — one line, by construction, at whatever size the window is currently rendering. The JS reads parentElement.clientHeight and never states an opinion about it. Two consequences you must respect:

  • The odometer paragraphs need white-space: nowrap. A title that wraps is two lines tall inside a one-line window, and the measurement is right while the layout is wrong.
  • Responsive overrides must resize the window (.slide-title { font-size: … }), not the paragraphs inside it (.slide-title-wrapper p { font-size: … }). Resizing the paragraph changes the line while leaving 1.22em computed against the window's old size — which is the very desync you just removed.

Assets / images

4 landscape photographs at 1200×840 (10:7), one series. This is the point of the component: same sitter distance, same lighting, same lens, same seamless-paper studio setup, varying only in the colour of the paper. Strict profile head-and-shoulders portraits work best because the repetition of the pose is what turns four pictures into one body of work.

Whatever you substitute, keep the rule: one shoot, one ratio, one distance, four grounds. Mixing a full-length shot into a set of head-and-shoulders, or a 16:9 crop into a set of 4:3, is what the framed-plate treatment is designed to make impossible to hide. Sample each file's backdrop colour and paste it into that slide's data-accent and its "Ground #XXXXXX" line — the accent must be the real colour of the paper, not a colour you liked.

File naming img1.jpg … img4.jpg. No logos or brand marks.

Behavior notes

  • One screen, no scroll hijack, no autoplay. On mobile the layout stacks (plate, then rail) and stays within a phone screen.
  • No wrap: navigation clamps at the first and last plate, and the + buttons reflect it with disabled.
  • Re-clicks during an in-flight tween are not guarded — GSAP retargets and overlaps. place() uses overwrite: true so the odometers never fight themselves; the image tweens are allowed to stack, which is the original feel.
  • prefers-reduced-motion is honoured in both directions: JS collapses every tween duration, CSS collapses every transition.
  • Accessibility is part of the build, not a retrofit: aria-current on the active thumbnail inside a labelled group, aria-label on the two marks, aria-live="polite" on the title window, arrow-key navigation, visible focus rings, and 46–48px touch targets throughout.

Images

This component ships with 4 reference assets, served publicly. Use them as-is to reproduce the demo faithfully, then swap in your own — the layout expects the same 10:7 ratio.

https://motionprompts.dev/c/camille-mormal-slider/img1.jpg
https://motionprompts.dev/c/camille-mormal-slider/img2.jpg
https://motionprompts.dev/c/camille-mormal-slider/img3.jpg
https://motionprompts.dev/c/camille-mormal-slider/img4.jpg

They are hotlinkable for prototyping. For anything you ship, replace them: they are licensed for demonstration of this component, not for redistribution.

Using this outside its demo page

This component is written as a complete page — that is how the demo is meant to look. If you are dropping it into an existing project, or combining it with other components, these are the things it declares at document level and that you need to move or reconcile first.

  • Palette on :root--paper, --ink, --ink-soft, --ink-faint, --rule, --accent, --edge, --chrome, --serif, --sans. These names are not namespaced and they collide: --ink is defined by 164 of the components in this catalogue and --paper by 94, each with different values — and they will also collide with whatever your own project defines. Move them onto the component's wrapper (.my-section { --ink: … }) or rename them with a prefix.
  • --accent is written to document.documentElement by paint() on every navigation. If you scope the palette to a wrapper, move that setProperty call onto the same wrapper element or the slider will keep recolouring your whole page.
  • **Rules on *, html, body** — the demo owns the whole document, so these set the page background, typography and resets. Dropped into an existing project they restyle the entire page, not just this section. Re-target them at the component's wrapper before using it.
  • --chrome: 13rem encodes the height of *this page's* masthead plus the stage padding. In a different shell, re-measure it or the plate will size itself against room it does not have.

Adapting this to React

Everything above describes a standalone document: one script that runs once behind DOMContentLoaded, closes over current and markRotation as plain variables, wires click handlers onto elements it queried from document, and additionally registers a keydown listener on document and a ResizeObserver on the title window. Nothing here expects to be asked to run a second time, and nothing undoes itself.

Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again before anything reaches the screen. The element-scoped listeners are harmless — they die with the nodes they were attached to. The two that are not are the ones bound above the component: the keydown listener on document and the ResizeObserver.

If the keydown listener is not removed on that first throwaway unmount, the remount adds a second one — two closures, each with its own current starting at 1 and its own markRotation starting at 0, both bound to the same document, both reacting to the same key press. Because the two closures track the same key sequence their indices stay numerically in step, which makes the bug easy to miss in testing and ugly in practice: one arrow key now runs animateSlide twice, so two incoming .img layers get appended instead of one. The first closure queries .slider-images and finds its own freshly-appended frame as the last entry — call it frame A. The second closure runs immediately after, re-queries, now finds frame A (not the plate that was actually on screen before the key press) sitting last, and pushes *that* off as "the outgoing image" while stacking its own frame B on top with a second wipe and a second parallax tween. The visible result is a plate that flashes in and is immediately shoved back out on every key press, plus the pruning step running twice per press. The stale ResizeObserver is quieter but worse-behaved: it holds a reference to a detached window node forever and keeps calling place(0) on odometers nobody is looking at.

None of this reproduces in a production build, because React only double-invokes effects in development. Treat the cleanup as part of the effect, not as an afterthought.

*(1) The entry point* — The script waits for DOMContentLoaded with no readyState guard. By the time a React component mounts, that event has already fired, so the body would simply never run — no slider, no error, nothing to debug. Delete the document.addEventListener("DOMContentLoaded", …) wrapper and move its body into a useEffect with an empty dependency array. current and markRotation do not need to become refs or state: they live entirely inside the effect and are rebuilt from scratch on every effect run, which is exactly the reset a fresh mount should produce.

*(2) Element lookups* — plate, sliderImages, titles, hexes, counter, railFill, marks, steps and thumbs are all resolved off document. Give the component's outermost element a root ref and resolve every one of them from it (rootRef.current.querySelector(".plate"), and so on) instead of from document. This matters most for the query inside animateSlide that finds the outgoing frame: during the instant both StrictMode passes coexist, an unscoped querySelectorAll can resolve against the departing copy of the markup and push the wrong node off screen. The one write that stays intentionally global is document.documentElement.style.setProperty("--accent", …) — and if you scope the palette to your wrapper, retarget it there too.

*(3) Cleanup* — place, paint and animateSlide never run synchronously while the effect sets up; they run later, from inside the handlers. A gsap.context whose factory only registers listeners auto-tracks nothing, because no tween is created during that synchronous pass — so wrap them as named context methods and call those from the handlers:

useEffect(() => {
  let current = 1;
  let markRotation = 0;

  const ctx = gsap.context((self) => {
    self.add("place", (duration) => { /* the three odometer tweens, y read from clientHeight */ });
    self.add("paint", () => { /* accent, thumbnails, rail scaleX, button disabled state */ });
    self.add("animateSlide", (direction) => { /* build the frame, wipe, push, parallax, prune, rotate */ });
  }, rootRef);

  const goTo = (index) => { /* clamp, set direction, ctx.animateSlide/place/paint */ };

  const onKey = (e) => {
    if (e.key === "ArrowLeft") goTo(current - 1);
    if (e.key === "ArrowRight") goTo(current + 1);
  };
  document.addEventListener("keydown", onKey);

  const ro = new ResizeObserver(() => ctx.place(0));
  ro.observe(titleWindowRef.current);

  return () => {
    document.removeEventListener("keydown", onKey);
    ro.disconnect();
    ctx.revert();
  };
}, []);

ctx.revert() alone would leave both the keydown listener and the ResizeObserver untouched — neither is a tween or a trigger, so the context has no record of them — which is exactly the leftover that produces the doubled navigation described above. Removing them explicitly in the same cleanup, alongside the revert, is what makes a StrictMode remount (and a real unmount on route change) leave nothing behind. Registering CustomEase and building the "hop" curve belong at module scope, not inside the effect — both are idempotent, global, one-time registrations.