Sidebar Slide-Out Menu — Staggered Cards Sliding In From The Right + Background Blur
Goal
Build a fullscreen hero page with a small "Get the Overlay" toggle pinned in the bottom-right corner. Clicking it slides a fixed column of three white cards in from the right edge of the screen with a fast GSAP 0.075s stagger on a power4.out ease, while the whole page content blurs to 15px and the toggle button itself slides off-screen to the right. Clicking any card reverses everything — the cards slide back out past the right edge, the toggle slides back into its corner, and the page un-blurs. On top of that, each card has pure-CSS hover behavior: hovering a card swaps it to a deep-blue fill and reveals its body copy, and the first card additionally pops in a large close (×) icon. It is entirely click- and hover-driven — no scroll, no autoplay.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) only — no GSAP plugins, no ScrollTrigger, no smooth-scroll library. Import as:
import gsap from "gsap";
Icons come from the Phosphor Icons web font, served from your own origin. Link one stylesheet per weight you use, in <head>:
<link rel="stylesheet" href="/vendor/phosphor/regular/style.css" />
<link rel="stylesheet" href="/vendor/phosphor/thin/style.css" />
Get them with npm i @phosphor-icons/web@2.1.2 and copy those weight folders from node_modules/@phosphor-icons/web/src/ (each is a style.css plus its .woff2). Do not use the package's own index.js: it injects these same stylesheets from cdn.jsdelivr.net. Used icon classes: ph ph-arrow-right (the arrow in the toggle) and ph-thin ph-x (the close × on card 1).
Layout / HTML
.container (fullscreen, position:relative — holds the site + toggle; this is what blurs)
.website-content
nav (fixed top bar, flex, 3 equal columns)
.logo "Motionprompts"
.cta "Subscribe" (center column)
.links (right column, flex-end)
a "About" a "Infos" a "Contact"
.hero (left-aligned headline block, vertically centered)
h1 "Elite web designs"
p (a paragraph of body copy)
.overlay-toggle (bottom-right white pill — the OPEN click target)
p "Get the Overlay"
p > i.ph.ph-arrow-right
.sidebar (fixed full-height column on the right; clips the cards)
.card (card 1)
.card-title
div "Showreel"
.close-btn > i.ph-thin.ph-x (large × — only on card 1)
.card-copy > p (body copy, hidden until hover)
.card (card 2)
.card-title "Community"
.card-copy > p
.card (card 3)
.card-title "Catalog"
.card-copy > p
Note the .sidebar is a sibling of .container, not inside it — so when .container blurs, the sidebar cards stay sharp. Use neutral, fictional labels (Motionprompts / Subscribe / About / Infos / Contact / Showreel / Community / Catalog) — no real brand names. Card body copies are short lorem-ipsum sentences.
Styling
Font: "Neue Montreal" (a clean neutral grotesque sans-serif). If unavailable, fall back to a similar sans (system-ui, Helvetica). html, body { width:100%; height:100%; background:#000; color:#fff; font-family:"Neue Montreal"; }.
Global reset: * { margin:0; padding:0; box-sizing:border-box; }.
Color tokens:
- Page background:
#000(black) - Text on hero:
#fff; hero paragraph muted grey#a0a0a0 - Toggle pill + cards default fill:
#fffwith#000text - Card hover fill:
#020B44(deep navy blue) with#ffftext
Structural / load-bearing CSS (these dimensions and the right offsets are what the GSAP tweens animate to/from — keep them exact):
.container:position: relative; width: 100vw; height: 100vh;fullscreen hero background imageno-repeat 50% 50%,background-size: cover.nav:position: fixed; width: 100%; display: flex; padding: 1.5em;withnav > div { flex: 1; }..cta { text-align: center; }..links { display: flex; justify-content: flex-end; gap: 2em; }.a { text-decoration: none; color: #fff; }..hero:position: absolute; top: 50%; transform: translateY(-50%); padding: 0 1.5em; width: 35%;..hero h1 { font-size: 40px; font-weight: 400; margin-bottom: 10px; }..hero p { color: #a0a0a0; line-height: 1.5; }..overlay-toggle:position: absolute; right: 0; bottom: 0; width: 250px; height: 150px; margin: 0.75em; padding: 1em; border-radius: 0.5em; background: #fff; color: #000; display: flex; justify-content: space-between; cursor: pointer;(label top-left, arrow icon top-right). Itsrightis the animated property (starts at the CSS0, effectivelyright: 0.75emvisually via the margin)..sidebar:position: fixed; top: 0; right: 0; width: 30vw; height: 100vh; padding: 0.75em; display: flex; flex-direction: column; gap: 0.75em; overflow: hidden; pointer-events: none;.overflow: hiddenclips the cards while they sit off-screen atright: -110%;pointer-events: noneis the closed state (JS toggles it toallwhen open so the cards become clickable)..card:position: relative; right: -110%;(this off-screen offset is the closed state the GSAP tween animates from)padding: 1em; flex: 1; background: #fff; color: #000; border-radius: 0.5em; overflow: hidden; display: flex; flex-direction: column; justify-content: space-between; cursor: pointer;. Three equal-height cards stacked vertically..card-title:font-size: 50px; letter-spacing: -0.035em;..card:nth-child(1) .card-title { display: flex; justify-content: space-between; }(title text on the left, close-btn on the right — only card 1)..close-btn:position: relative; font-size: 60px; transform: scale(0); transition: transform 0.3s; transform-origin: center;— hidden by default, scaled up on card-1 hover..card-copy:position: relative; opacity: 0; transform: translateY(20px); font-size: 15px; transition: transform 0.3s, opacity 0.3s;— hidden by default, revealed on hover.
Pure-CSS hover behavior (NOT GSAP):
.card:hover { background: #020B44; color: #fff; }
.card:hover .card-copy { opacity: 1; transform: translateY(0px); } /* copy slides up + fades in over 0.3s */
.card:nth-child(1):hover .close-btn { transform: scale(1); } /* × pops from scale 0→1 over 0.3s */
GSAP effect (exhaustive)
No plugins, no timeline object — the effect is built from standalone gsap.to() tweens fired inside two functions (animateCardsIn on open, animateCardsOut on close). Grab refs once on DOMContentLoaded:
const container = document.querySelector(".container");
const sidebar = document.querySelector(".sidebar");
const cards = gsap.utils.toArray(".card"); // the 3 cards, in DOM order
const overlayToggle = document.querySelector(".overlay-toggle");
Open — animateCardsIn()
Fires three parallel standalone tweens, all starting on the same tick (see note on "<" below):
- Toggle slides off-screen right:
``js gsap.to(overlayToggle, { right: "-500px", duration: 1, ease: "power4.out" }); ` Animates the pill's right from its resting 0 to -500px, sliding it out past the right edge. duration: 1, ease: "power4.out"`.
- Cards slide in, staggered:
``js gsap.to(cards, { right: "0%", stagger: 0.075, duration: 1, ease: "power4.out" }, "<"); ` Animates each card's right from the CSS -110% (fully off-screen to the right, clipped by the sidebar's overflow: hidden) to 0% (flush in place). stagger: 0.075 fires them 0.075s apart in DOM order — card 1 first (Showreel), then card 2 (Community), then card 3 (Catalog). duration: 1 each, ease: "power4.out"` (fast launch, long smooth settle).
- Page content blurs:
``js gsap.to(container, { filter: "blur(15px)", duration: 1, immediateRender: false }, "<"); ` Animates the .container's CSS filter from blur(0px) to blur(15px) over duration: 1. No ease specified → GSAP default power1.out. immediateRender: false prevents GSAP from snapping the blur to its end value before the tween runs. Because the sidebar is a sibling (outside .container`), the incoming cards remain crisp while the site behind them goes soft.
Close — animateCardsOut()
The mirror image, again three parallel standalone tweens:
- Toggle slides back into its corner:
``js gsap.to(overlayToggle, { right: "0px", duration: 1, ease: "power4.out" }); ` right: -500px → 0px, duration: 1, ease: "power4.out"`.
- Cards slide back out, staggered:
``js gsap.to(cards, { right: "-110%", stagger: 0.075, duration: 1, ease: "power4.out" }); ` right: 0% → -110% (back off-screen), same stagger: 0.075 in DOM order, duration: 1, ease: "power4.out"`.
- Page un-blurs:
``js gsap.to(container, { filter: "blur(0px)", duration: 1, immediateRender: false }, "<"); ` blur(15px) → blur(0px), duration: 1, default power1.out ease, immediateRender: false`.
Note on the "<" third argument: in the source it is passed to some of these standalone gsap.to() calls, but the position parameter ("<") is only meaningful on timeline methods — on the global gsap.to() it is simply ignored. The practical result is that all three tweens in each function start together on the same tick. (You may include it verbatim for fidelity, or omit it — behavior is identical.)
Wiring
overlayToggle.addEventListener("click", () => {
sidebar.style.pointerEvents = "all"; // enable card clicks while open
animateCardsIn();
});
cards.forEach((card) => {
card.addEventListener("click", () => {
sidebar.style.pointerEvents = "none"; // disable card clicks while closed
animateCardsOut();
});
});
- Open: clicking the
.overlay-togglesets the sidebar topointer-events: all(so the cards become clickable) and runsanimateCardsIn(). - Close: clicking any card sets the sidebar back to
pointer-events: noneand runsanimateCardsOut(). (Every card closes the menu, including card 1 — the × icon is decorative CSS, not a separate handler.) - There is no re-entrancy guard; GSAP overwrites in-flight tweens naturally if the user clicks rapidly.
Wrap everything in document.addEventListener("DOMContentLoaded", () => { ... }).
Assets / images
- 1 hero background image — role: *fullscreen background behind the whole site, sits inside
.containerand is what blurs*. A moody, dark, photographic image (low-key, cinematic). Displayedbackground-size: coverat50% 50%. Landscape ~16:9 (e.g. 1920×1080 or larger). Because the UI text is white and the card hover fill is a deep navy, a darker image reads best.
Behavior notes
- Entirely click + hover driven — no scroll, no ScrollTrigger, no loops, no autoplay.
- The card enter/exit + toggle slide + page blur are GSAP; the card color swap, body-copy reveal, and card-1 × pop are pure CSS
:hovertransitions (all0.3s). - Closed state is expressed in CSS (
.card { right: -110% },.sidebar { pointer-events: none }); JS only ever tweens toward/away from it and togglespointer-events. - Responsive (
max-width: 900px):.hero { top: 25%; width: 100%; }and.sidebar { width: 100vw; }(the sidebar becomes a full-width overlay; cards still slide in from the right with the same stagger).
Images
This component ships with 1 reference asset, 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/sidebar-slide-out-menu/hero.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—--ink,--paper,--paper-dim,--hover-ink,--gold,--hairline. These names are not namespaced and they collide:--inkis defined by 164 of the 219 components in this catalogue,--paperby 94,--mutedby 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
*,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. - Full-screen overlay — a fixed element covers the viewport (a loader or transition). Only one may exist per page and it must remove itself when done. If your page already has one, keep that and drop this; otherwise the second silently hides the first.
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 waits for DOMContentLoaded. By the time a React component mounts, that event has already fired, so the listener is never called and the effect never runs — no error, no animation, nothing to debug. Delete the listener and put its body directly inside a useEffect with an empty dependency array.
*(2) Element lookups* — Every document.querySelector in the code above assumes this component owns the document. Give the component a root ref, render it on the outermost element, and scope every lookup to it. Unscoped selectors are not a style problem here: during the StrictMode remount two copies of the subtree exist for an instant, and an unscoped selector will bind to the one that is on its way out. This component makes the ref placement non-obvious: the layout above is explicit that .sidebar is a sibling of .container, not a descendant — that separation is what keeps the three cards sharp while .container blurs — so the root ref has to sit on a wrapper enclosing both, not on .container alone; a ref scoped to .container would put the sidebar, its cards and the toggle's click target outside the subtree it can query. Replace gsap.utils.toArray(".card") with Array.from(root.querySelectorAll(".card")) against that same scoped root, for the same reason.
*(3) Cleanup* — Wrap the two tween-firing functions in a gsap.context scoped to the root ref, and revert that context in the cleanup:
useEffect(() => {
const root = rootRef.current;
const container = root.querySelector(".container");
const sidebar = root.querySelector(".sidebar");
const overlayToggle = root.querySelector(".overlay-toggle");
const cards = Array.from(root.querySelectorAll(".card"));
const ctx = gsap.context((self) => {
self.add("animateCardsIn", () => {
gsap.to(overlayToggle, { right: "-500px" /* duration/ease as narrated above */ });
gsap.to(cards, { right: "0%" /* stagger/duration/ease as narrated above */ });
gsap.to(container, { filter: "blur(15px)", immediateRender: false });
});
self.add("animateCardsOut", () => {
gsap.to(overlayToggle, { right: "0px" /* duration/ease as narrated above */ });
gsap.to(cards, { right: "-110%" /* stagger/duration/ease as narrated above */ });
gsap.to(container, { filter: "blur(0px)", immediateRender: false });
});
}, rootRef);
const openMenu = () => { sidebar.style.pointerEvents = "all"; ctx.animateCardsIn(); };
const closeMenu = () => { sidebar.style.pointerEvents = "none"; ctx.animateCardsOut(); };
overlayToggle.addEventListener("click", openMenu);
cards.forEach((card) => card.addEventListener("click", closeMenu));
return () => {
overlayToggle.removeEventListener("click", openMenu);
cards.forEach((card) => card.removeEventListener("click", closeMenu));
ctx.revert();
};
}, []);
animateCardsIn and animateCardsOut are never called during the factory's synchronous pass — they only run later, from a click. That is exactly the situation self.add exists for. Inside the factory the variable is self, never ctx: ctx is still in its temporal dead zone at that point, and referencing it throws Cannot access 'ctx' before initialization. And self.add has two overloads that do different things. The single-argument form runs its function immediately, during the factory pass, and hands it the context object rather than any argument you meant to pass — using it here would fire the open/close tweens once at mount and leave ctx.animateCardsIn undefined, so the toggle's click handler would throw. The name-plus-function form used above only registers the method; it stays inert until the click handler calls ctx.animateCardsIn() or ctx.animateCardsOut(), at which point gsap.context attributes every tween created inside to this context, exactly as if it had run synchronously. Skip self.add and call gsap.to() straight from openMenu/closeMenu instead, and the tweens those clicks create are invisible to ctx.revert(): a StrictMode remount leaves the first pass's tweens still writable by a second effect pass that now also controls the same elements, and the two start fighting over the same inline right/filter values on alternating clicks.
ctx.revert() only undoes what it tracked: the right and filter inline styles the two registered tweens write to the toggle, the three cards and .container. It does not touch the plain click listeners on overlayToggle or the cards — those are ordinary DOM subscriptions, not GSAP objects — so they need their own removeEventListener in the same cleanup, passing the same function references addEventListener received. Skip that and the StrictMode remount leaves the first pass's listeners attached to the same nodes the second pass just wired up: one click on the toggle now runs animateCardsIn from both closures, firing the same pair of tweens twice on the same tick. That is harmless today, because GSAP overwrites a tween already targeting the same properties — but it silently doubles the moment animateCardsIn/animateCardsOut grows any side effect that isn't idempotent, such as an analytics call or a counter.