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 serif headline block. Three small icon buttons at the bottom switch between three named arrangements — chaos (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
.itemwith a uniqueid(music,cd,dialog,folder,macmini,paper,passport,portrait,appicon,lighter,cursor) — the JS keys sizes and per-mode positions off these ids. .modesholds exactly three<button>elements, each with adata-modeofchaos/cleanup/notebook. The first (chaos) starts with classactive. Icons are inline monoline SVGs (stroke="currentColor",stroke-width="32", viewBox0 0 512 512): a lightning bolt for chaos, a 2×2 rounded-square grid for cleanup, an open book for notebook.
Styling
Fonts (Google Fonts): body "DM Sans"; headline "Instrument Serif".
Palette (CSS vars on :root)
--base-100: #f5f2ed(warm off-white page background)--base-200: #e0dfd7(button fill/border)--base-300: #5f5f5f(muted text / icon color)--base-400: #171717(near-black text)
Reset: * { margin:0; padding:0; box-sizing:border-box; }. body { font-family:"DM Sans"; background:var(--base-100); color:var(--base-400); }. 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:"Instrument Serif"; font-size:4rem; font-weight:600; letter-spacing:-0.02rem; line-height:1; }.header p { font-size:0.95rem; line-height:1.75; color:var(--base-300); }
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 { width:3rem; height:3rem; font-size:1.25rem; color:var(--base-300); background:var(--base-100); border:1px solid var(--base-200); border-radius:0.5rem; display:flex; justify-content:center; align-items:center; transition:all 500ms ease; cursor:pointer; }.modes button:active { transform:scale(0.9); }.modes button.active { background:var(--base-200); border:1px solid var(--base-200); }
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.
chaos — header {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
cleanup — header {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
notebook — header {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.getStatesnapshots the header + all 11 objects.setLayoutmutates 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, tweeningx,y,width,height, androtationfor each element simultaneously. - duration:
1.25s. 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 toposition:absolutefor 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):
music— a light, minimal "now playing" music-player UI card with album art, track title, progress bar and playback controls.cd— a shiny silver compact disc, top-down, with rainbow light refractions.dialog— a small retro OS-style error dialog window with a title bar, warning icon and an OK button.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).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).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).passport— a brown leather passport booklet with embossed lettering and a small silver compass resting on it.portrait— an illustrated stylized side-profile portrait poster of a person, with a white border and soft drop shadow (reads like a framed print).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).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).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.getStatere-reads the true current layout each time.