All components

Creative Clutter Flip Layouts

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

Open live demo ↗ Raw prompt (.md)

What it does

A messy-desk hero of 11 floating cut-out items scattered around a centered Space Grotesk display header, with three arrangements (chaos scatter, tidy cleanup grid, notebook cluster) switched via bottom icon buttons. Each switch captures Flip.getState, applies new percent-based positions and rotations with gsap.set, then animates with Flip.from (1.25s, power3.inOut, 0.1s center-out stagger, absolute); the layout recalculates on window resize.

How it's built

Categoryinteractive
Techgsap
GSAP pluginsFlip
Complexitysection
Performance costlight
Mobile-safeyes

flip layout-switch collage desk stagger grid buttons

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

Creative Clutter — Flip Layout Switch

Goal

Build a full-viewport, light "messy desk" hero: 11 cut-out desk objects (music player card, CD, error dialog, folder icon, mini computer, ruled paper, passport, portrait poster, app icon, lighter, cursor) float at scattered positions and angles around a centered display headline block. Three small icon buttons at the bottom switch between three named arrangementschaos (a random-looking scatter), cleanup (a tidier, un-rotated spread with the header pushed to the right), and notebook (a tight cluster). The star effect: on each button press, GSAP Flip captures the current layout and smoothly morphs every object AND the header from its old position/size/rotation to the new one, with a slow power3.inOut ease and a center-out stagger, so the whole desk re-organizes itself in one fluid choreographed move.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugin Flip (imported from gsap/all and registered with gsap.registerPlugin(Flip)). No smooth-scroll, no other libraries. Ship one index.html (<link rel="stylesheet" href="./styles.css"> and <script type="module" src="./script.js">), one styles.css, one ES-module script.js. Must run in a fresh Vite + npm project.

Layout / HTML

<section class="desk">
  <div class="header">
    <h1>Creative Clutter</h1>
    <p>The best ideas live somewhere between a coffee stain and a half-open
       folder, scattered things have a way of finding others when you stop
       trying to organize.</p>
  </div>

  <div class="item" id="music"><img src="<music player card>" /></div>
  <div class="item" id="cd"><img src="<compact disc>" /></div>
  <div class="item" id="dialog"><img src="<retro error dialog>" /></div>
  <div class="item" id="folder"><img src="<folder icon>" /></div>
  <div class="item" id="macmini"><img src="<mini desktop computer>" /></div>
  <div class="item" id="paper"><img src="<ruled notebook sheet>" /></div>
  <div class="item" id="passport"><img src="<passport booklet>" /></div>
  <div class="item" id="portrait"><img src="<framed portrait poster>" /></div>
  <div class="item" id="appicon"><img src="<rounded-square app icon>" /></div>
  <div class="item" id="lighter"><img src="<disposable lighter>" /></div>
  <div class="item" id="cursor"><img src="<arrow cursor + spinner>" /></div>

  <div class="modes">
    <button class="active" data-mode="chaos" aria-label="Chaos mode"><svg…/></button>
    <button data-mode="cleanup" aria-label="Cleanup mode"><svg…/></button>
    <button data-mode="notebook" aria-label="Notebook mode"><svg…/></button>
  </div>
</section>
  • Every draggable object is a .item with a unique id (music, cd, dialog, folder, macmini, paper, passport, portrait, appicon, lighter, cursor) — the JS keys sizes and per-mode positions off these ids.
  • .modes holds exactly three <button> elements, each with a data-mode of chaos / cleanup / notebook. The first (chaos) starts with class active. Icons are inline monoline SVGs (stroke="currentColor", stroke-width="32", viewBox 0 0 512 512): a lightning bolt for chaos, a 2×2 rounded-square grid for cleanup, an open book for notebook.

Styling

Fonts: body Inter; headline Space Grotesk; the small uppercase labels and the mode pills Space Mono. Two voices on purpose — the grotesk pair is the machine, the mono is the label-maker.

Palette (CSS vars on :root)

:root {
  --desk: #f2f2f2;      /* the page: a cool neutral desk */
  --desk-hi: #f7f7f7;
  --desk-low: #e8e8ea;
  --desk-edge: #dfdfe2;
  --card: #ffffff;      /* the mode pills at rest */
  --ink: #16161a;
  --muted: #2b2b31;
  --accent: #ff4e45;    /* one warm red: the active pill and the spellcheck squiggle */
}

Reset: * { margin:0; padding:0; box-sizing:border-box; }. body { font-family:"Inter", sans-serif; background: var(--desk); color: var(--ink); }. img { width:100%; height:100%; object-fit:contain; } (so each object keeps its own aspect ratio, centered inside its square box).

Stage.desk { position:relative; width:100%; height:100svh; max-width:1400px; margin:0 auto; }. This element's measured offsetWidth/offsetHeight drive all position math.

Header.header { position:absolute; width:400px; text-align:center; display:flex; flex-direction:column; gap:0.75rem; pointer-events:none; z-index:10; }.

  • .header h1 { font-family:"Space Grotesk"; font-weight:600; font-size:clamp(3rem, 5.6vw, 4.8rem); letter-spacing:-0.03em; line-height:0.98; text-wrap:balance; } — and one word inside it is an <em> reset to font-style:normal and given text-decoration: underline wavy var(--accent): the spellcheck moment, which is where the single red lives.
  • .lede { font-size:0.95rem; line-height:1.7; color: var(--muted); max-width:40ch; margin:0 auto; }

Items.item { position:absolute; will-change:top,left,transform; }. They are positioned entirely by GSAP via inline transforms + inline width/height (see below), never by CSS coordinates.

Mode buttons.modes { position:fixed; bottom:7.5svh; left:50%; transform:translateX(-50%); display:flex; gap:0.5rem; z-index:10; }.

  • .modes button { display:flex; align-items:center; gap:0.45rem; padding:0.6rem 1.05rem; font-family:"Space Mono"; font-size:0.6875rem; font-weight:700; text-transform:uppercase; letter-spacing:0.08em; color: var(--ink); background: var(--card); border:1px solid rgba(22,22,26,.16); border-radius:999px; box-shadow:0 1px 2px rgba(22,22,26,.07); cursor:pointer; } — pills with a label, not bare icon squares.
  • .modes button:active { transform:scale(0.9); }
  • .modes button.active { background: var(--accent); border-color: var(--accent); color: var(--ink); } — the active mode is the warm red pill.

Responsive

  • @media (max-width:1400px) { .desk { overflow-x:hidden; } }
  • @media (max-width:1000px): overlay a semi-transparent wash with .desk::after { content:""; position:absolute; inset:0; width:100%; height:100%; background-color:rgba(245,242,237,0.5); }, and desaturate the objects with .item { filter:saturate(0); }.

GSAP effect (be exhaustive)

Plugin & targets
import gsap from "gsap";
import { Flip } from "gsap/all";
gsap.registerPlugin(Flip);

const desk    = document.querySelector(".desk");
const header  = document.querySelector(".header");
const items   = gsap.utils.toArray(".item");   // the 11 objects
const flipTargets = [header, ...items];         // 12 elements flip together
const switches = document.querySelectorAll(".modes button");
let activeMode = "chaos";
Object sizes (square box, px — used as both width and height)
music: 325   appicon: 100   cd: 400    cursor: 125   dialog: 300   folder: 150
lighter: 225   macmini: 250   paper: 375   passport: 250   portrait: 375

Each object's box is square; object-fit:contain letterboxes the real image inside it.

Arrangements (per-mode data)

Positions are percentages of the desk's width (x) and height (y), applied as gsap.set translate offsets (top-left of the box lands at that percent point; values can be negative or >100 so boxes bleed off-stage). rotation in degrees. The header also carries x/y percentages plus a center flag.

chaosheader {x:50, y:47.5, center:true}

music    x:-2.5  y:-2.5  r:-15      appicon  x:20    y:15    r:5
cd       x:72.5  y:5     r:0        cursor   x:72.5  y:75    r:0
dialog   x:80    y:60    r:15       folder   x:90    y:50    r:5
lighter  x:2.5   y:45    r:-10      macmini  x:9.5   y:55    r:15
paper    x:5     y:15    r:10       passport x:-2.5  y:65    r:-35
portrait x:65    y:20    r:-5

cleanupheader {x:70, y:37.5, center:false} (header shifts right, no center-offset, all objects un-rotated)

music    x:76.5  y:-5    r:0        appicon  x:64.5  y:6     r:0
cd       x:0     y:47.5  r:0        cursor   x:63.5  y:23    r:0
dialog   x:34.5  y:59    r:0        folder   x:24.5  y:33    r:0
lighter  x:-6    y:3.5   r:0        macmini  x:82.5  y:66    r:0
paper    x:9     y:-3.5  r:0        passport x:60    y:65.5  r:0
portrait x:36.5  y:5.5   r:0

notebookheader {x:50, y:47.5, center:true} (tight, tilted cluster)

music    x:45    y:0.5   r:20       appicon  x:65    y:70    r:25
cd       x:27.5  y:15    r:10       cursor   x:75    y:35    r:0
dialog   x:30    y:57.5  r:10       folder   x:25    y:40    r:10
lighter  x:30    y:7.5   r:30       macmini  x:50    y:50    r:-5
paper    x:10    y:10    r:-30      passport x:16.5  y:50    r:-20
portrait x:57.5  y:20    r:10
setLayout(mode) — writes the target layout instantly (no animation)
function setLayout(mode) {
  const deskWidth  = desk.offsetWidth;
  const deskHeight = desk.offsetHeight;
  const layout     = arrangements[mode];
  const isMobile   = deskWidth < 1000;

  // header offset: subtract half its own size only when centered (or always on mobile)
  const offsetX = isMobile ? header.offsetWidth  / 2
                : layout.header.center ? header.offsetWidth  / 2 : 0;
  const offsetY = isMobile ? header.offsetHeight / 2
                : layout.header.center ? header.offsetHeight / 2 : 0;
  const headerX = isMobile ? 50   : layout.header.x;
  const headerY = isMobile ? 47.5 : layout.header.y;

  gsap.set(header, {
    x: (headerX / 100) * deskWidth  - offsetX,
    y: (headerY / 100) * deskHeight - offsetY,
    rotation: 0,
  });

  layout.items.forEach(({ id, x, y, rotation }) => {
    gsap.set(`#${id}`, {
      x: (x / 100) * deskWidth,
      y: (y / 100) * deskHeight,
      width:  itemSizes[id],
      height: itemSizes[id],
      rotation,
    });
  });
}

Notes: the header's percent point is (x,y), and when center:true (chaos, notebook) we subtract half the header's own width/height so it is centered on that point; in cleanup (center:false) the header's top-left anchors at the point instead, sliding it toward the right. On mobile (deskWidth < 1000) the header is forced to the centered (50, 47.5) position regardless of mode.

switchMode(mode) — the Flip morph (the star effect)
function switchMode(mode) {
  if (mode === activeMode) return;
  const state = Flip.getState(flipTargets);   // record current position/size/rotation of all 12
  setLayout(mode);                            // jump them to the new layout instantly
  Flip.from(state, {
    duration: 1.25,
    ease: "power3.inOut",
    stagger: { amount: 0.1, from: "center" },
    absolute: true,
  });
  activeMode = mode;
}
  • Mechanism: Flip.getState snapshots the header + all 11 objects. setLayout mutates them to the destination layout in a single frame. Flip.from(state, …) then plays the animation from the recorded snapshot to the now-current layout, tweening x, y, width, height, and rotation for each element simultaneously.
  • duration: 1.25 s. ease: "power3.inOut" (slow start, fast middle, slow settle). stagger: { amount: 0.1, from: "center" } — total 0.1 s of stagger spread across the 12 targets, radiating out from the center of the array so the middle objects lead and the outer ones follow. absolute: true — Flip pins each element to position:absolute for the duration so the staggered moves don't reflow each other.
  • No opacity/scale keyframes; the entire visual change is position + size + rotation interpolation.
Wiring
setLayout("chaos");   // initial paint, no animation

switches.forEach((btn) => {
  btn.addEventListener("click", () => {
    switches.forEach((b) => b.classList.remove("active"));
    btn.classList.add("active");
    switchMode(btn.dataset.mode);
  });
});

window.addEventListener("resize", () => setLayout(activeMode));

On resize, setLayout(activeMode) re-runs to re-derive pixel positions from the new desk size — instant, no Flip animation (only mode-button clicks trigger the Flip morph).

Assets / images

11 cut-out objects, each a PNG on a transparent background, framed roughly to fit inside a square box (the box side equals the size listed above; images are object-fit:contain, so real aspect ratios vary):

  1. music — a light, minimal "now playing" music-player UI card with album art, track title, progress bar and playback controls.
  2. cd — a shiny silver compact disc, top-down, with rainbow light refractions.
  3. dialog — a small retro OS-style error dialog window with a title bar, warning icon and an OK button.
  4. folder — a plain glossy OS-style folder icon in flat teal / turquoise with a soft rounded tab, no emblem or label, and a faint magenta rim glow (roughly landscape, slightly wider than tall).
  5. macmini — a compact silver / aluminium square desktop-computer unit seen from a top-front 3/4 angle, with a small dark logo emblem centered on its top face and a subtle magenta edge glow (near-square footprint).
  6. paper — a neat stack of blank white printer/paper sheets seen from a 3/4 isometric angle, edges lightly defined, with a faint magenta rim shadow (landscape crop, clearly wider than tall).
  7. passport — a brown leather passport booklet with embossed lettering and a small silver compass resting on it.
  8. portrait — an illustrated stylized side-profile portrait poster of a person, with a white border and soft drop shadow (reads like a framed print).
  9. appicon — a near-black / charcoal rounded-square app icon inside a bright silver-white frame, filled with thin white geometric guide lines (concentric circles, diagonals and a grid), wrapped in a soft pink/magenta outer glow (square).
  10. lighter — a green disposable pocket lighter standing upright, with a brushed-silver metal hood and a small red ignition button (tall portrait crop, much taller than wide).
  11. cursor — a black arrow cursor next to a rainbow spinning wait-cursor / beachball.

No client or third-party branding anywhere; keep the sticky-note text neutral.

Behavior notes

  • Desktop-first: the scatter is tuned for wide viewports; below 1000px the objects desaturate (filter:saturate(0)), a translucent wash is laid over the desk, and the header is force-centered.
  • Layout is fully derived from percentages of the live desk size, so it stays correctly placed on every resize.
  • The Flip morph fires only on a mode-button click and is a no-op if you click the already-active mode. Repeated switching between the three modes should always animate cleanly because Flip.getState re-reads the true current layout each time.

Images

This component ships with 11 reference assets, served publicly. Use them as-is to reproduce the demo faithfully, then swap in your own — the layout expects the same aspect ratios.

https://motionprompts.dev/c/creative-clutter/appicon.png
https://motionprompts.dev/c/creative-clutter/cd.png
https://motionprompts.dev/c/creative-clutter/cursor.png
https://motionprompts.dev/c/creative-clutter/dialog.png
https://motionprompts.dev/c/creative-clutter/folder.png
https://motionprompts.dev/c/creative-clutter/lighter.png
… 5 more under https://motionprompts.dev/c/creative-clutter/

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--desk, --desk-hi, --desk-low, --desk-edge, --card, --ink, --muted, --accent. These names are not namespaced and they collide: --ink is defined by 164 of the 219 components in this catalogue, --paper by 94, --muted by 80, 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.
  • **Rules on *, 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.

Adapting this to React

Everything above describes a standalone document: one script that runs once, reaches into the page with document.querySelector, and never has to undo itself. React withdraws all three of those guarantees at once, and it does it quietly — the component renders, looks right for a moment, and then misbehaves in a way that does not point back at any of this.

Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again before anything reaches the screen. Setup that runs twice with teardown that runs never leaves you two of everything: two triggers on the same element disagreeing about the same scrub, two smooth scrollers pulling on the same wheel event. The visible symptom is jitter or doubled speed, and it will not reproduce in a production build, because React only does the double mount in development. Treat the cleanup as part of the effect, not as an afterthought.

*(1) The entry point* — The script runs at the top level, the moment the module is evaluated: the desk, header, items and switches lookups, the itemSizes and arrangements tables, and the three closing statements — the initial setLayout("chaos") paint, the switches.forEach click wiring, and the resize listener — all execute at import time, before your component has rendered the eleven .item boxes or the three mode buttons. Move all of it into a useEffect with an empty dependency array. Do not leave it in the component body: that re-runs on every render and would stack a second set of click and resize listeners on top of the ones already attached.

*(2) Element lookups* — desk and header are captured once via document.querySelector and reused for the object's whole life; items is captured once via gsap.utils.toArray(".item"). Give the root <section class="desk"> a ref and reuse it as desk — it is the same element gsap.context's scope argument needs anyway — and put a second ref on .header, since setLayout reads its own offsetWidth/offsetHeight to center or offset it. The .item collection and the per-object gsap.set(\#${id}\, …) calls inside setLayout don't need refs of their own: once everything runs inside a gsap.context scoped to the root, GSAP resolves every string selector called within it — class or id — against that root's descendants instead of the whole document. That matters most for the id lookups, because ids are only unique within one copy of the DOM, and during the StrictMode remount two copies of the eleven objects exist for an instant, each with its own #cd, #folder, and so on. An unscoped #id lookup returns whichever copy is first in document order, not necessarily the one that is staying; scoping through the context is what keeps each mount's setLayout calls talking only to its own eleven elements.

*(3) Cleanup* — Wrap the layout math and the Flip morph in a gsap.context scoped to the root ref, and revert it in the cleanup. gsap.context accepts a return value from the function you pass it: if that function returns its own cleanup callback, ctx.revert() runs that callback alongside undoing whatever GSAP wrote, which matters here because the resize listener is a plain addEventListener, not a tween or a Flip call, so the context doesn't know about it on its own:

useEffect(() => {
  const modeRef = { current: "chaos" };

  const ctx = gsap.context((self) => {
    const items = gsap.utils.toArray(".item");
    const flipTargets = [headerRef.current, ...items];

    function setLayout(mode) {
      /* the same percent-to-pixel math as above, now scoped to the root ref */
    }

    self.add("switchMode", (mode) => {
      if (mode === modeRef.current) return;
      const state = Flip.getState(flipTargets);
      setLayout(mode);
      Flip.from(state, {
        /* the same values already named above, unchanged */
        absolute: true,
      });
      modeRef.current = mode;
    });

    setLayout("chaos");

    const onResize = () => setLayout(modeRef.current);
    window.addEventListener("resize", onResize);
    return () => window.removeEventListener("resize", onResize);
  }, rootRef);

  ctxRef.current = ctx;
  return () => ctx.revert();
}, []);

Two things break if this is ported literally. First, switchMode has to be reachable from the three button clicks, which live in JSX outside this effect — register it as a named method on the context, called later as ctx.switchMode(mode), and not as an immediately-invoked self.add(fn). The single-argument form runs synchronously during the factory pass and hands the callback the GSAP context itself as its argument, not a mode string, so the setLayout call inside it would try to read .center and .items off the context object and throw before any click ever happens. Stash the context in a ref (ctxRef) so the click handlers, defined in JSX, can call ctxRef.current.switchMode(mode). Rewrite the switches.forEach(...) wiring as three JSX onClick props instead of the imperative loop — React already owns attaching and detaching those, so the only listener left needing manual teardown is the one on window, which the factory's returned cleanup above provides. gsap.registerPlugin(Flip) itself stays at module scope, outside the effect; calling it again on every mount is harmless but buys nothing.

Second, activeMode in the original script quietly does two jobs: it's the guard switchMode reads to skip a repeat click on the already-active mode, and it's the flag that decides which button renders with the active class. Those need to split in React. Keep the guard in modeRef above — a plain mutable ref, not useState — because putting it in state and listing it as an effect dependency would tear the whole context down and rebuild it on every switch, calling ctx.revert() while a Flip.from tween is still mid-flight on those same twelve elements and undoing the very transform it's animating. Drive the button's active styling from a separate useState, updated in the click handler right alongside the ctxRef.current.switchMode(mode) call, but never added to the effect's dependency array.