GODRICH

9 CSS Sidebar Animations — Minimal JS

A css sidebar animation is a panel pinned to a screen edge that slides, fades, snaps, or scales open on a trigger, and these nine build all of it with minimal

Auto-plays · click a tile to jump to its section · all nine in one zip

The nine below move from the plainest hand-off toward the most custom transform work. 01 is the slide-in a sidebar reaches for first, and 02 hands that same job to the browser's own <dialog> element instead of writing it by hand. 03 through 07 each change what "open" means — staying in place, snapping through steps, pushing the content aside, widening on hover, or reacting to a neighboring scroll — and 08 and 09 close it out with a nested layer inside the drawer and the same idea rebuilt as a bottom tab bar for narrow screens.

01Left slide drawer

The panel starts translated fully off-screen to the left and slides to translateX(0) while a dark overlay fades in behind it through opacity. It locks Tab cycling inside the panel with a real focus trap, locks the rest of the page with inert, and closes on Escape or a click on the overlay. It suits left-hand navigation in dashboards and storefronts.

translateX(-100%)포커스 트랩inert
.drawer {
  position: fixed; left: 0; top: 0; bottom: 0;
  transform: translateX(-100%);
  width: min(76vw, 240px);
  transition: transform $duration $easing;
}
.overlay {
  position: fixed; inset: 0;
  opacity: 0; pointer-events: none;
  transition: opacity $duration $easing;
}
.stage.is-open .overlay { opacity: 1; pointer-events: auto; }
.stage.is-open .drawer  { transform: translateX(0); pointer-events: auto; }

02Native dialog drawer

A native <dialog> pinned to the left edge opens with one showModal() call, and the browser takes over focus movement, Escape handling, and outside-click blocking through its own ::backdrop. The real transition leans on @starting-style plus allow-discrete so the slide-in still animates the moment the element enters the top layer. It suits a settings panel that can rely on what the browser already guarantees.

<dialog>::backdropESC 공짜
.drawer {
  position: fixed; inset: 0 auto 0 0; margin: 0;
  width: min(76vw, 240px); height: 100%; max-height: none;
  border: 0; border-radius: 0;
  background: $stage-paper; color: $stage-ink;
  &::backdrop { background: rgba($stage-ink, .55); }
}
.drawer {
  transition: transform $duration $easing,
    overlay $duration allow-discrete, display $duration allow-discrete;
  transform: translateX(-100%);
}
.drawer[open] { transform: translateX(0); }
@starting-style {
  .drawer[open] { transform: translateX(-100%); }
}

03Overlay fade

The panel never travels — it only appears through opacity and a transform: scale(.96) growing to scale(1), unlike 01's edge-to-edge slide. Behind it, the overlay adds a backdrop-filter blur instead of a flat dark tint. It suits filter and option panels that open in place rather than crossing the screen.

opacity+scalebackdrop-filter위치 고정
.overlay {
  position: fixed; inset: 0;
  background: rgba($stage-ink, .28);
  backdrop-filter: blur(0px);
  opacity: 0; pointer-events: none;
  transition: opacity $duration $easing, backdrop-filter $duration $easing;
}
.drawer {
  position: fixed; left: 0; top: 0; bottom: 0;
  transform: scale(.96);
  transform-origin: left center;
  opacity: 0;
  transition: transform $duration $easing, opacity $duration $easing;
}
.stage.is-open .overlay { opacity: 1; backdrop-filter: blur(6px); }
.stage.is-open .drawer  { opacity: 1; transform: scale(1); }

04Bottom sheet snap

A handle button cycles a data-snap attribute through three fixed values, and each value maps to exactly one transform: translate(-50%, N%) rule the sheet snaps straight to — no drag coordinates get tracked at all. Escape and an overlay click always reset it to half on close. It matches the staged bottom sheets map apps use to expand a place list step by step.

3단 스냅peek·half·full손잡이 클릭
.sheet {
  position: fixed; left: 50%; bottom: 0; top: auto;
  transform: translate(-50%, 100%);
  transition: transform $duration $easing;
}
.stage.is-open .sheet[data-snap="peek"] { transform: translate(-50%, 78%); }
.stage.is-open .sheet[data-snap="half"] { transform: translate(-50%, 40%); }
.stage.is-open .sheet[data-snap="full"] { transform: translate(-50%, 0); }

05Push content

The sidebar and the content beside it both animate translateX over the same duration — the drawer to 0, the content to 40% — so two transforms redistribute the space instead of one overlay dimming it. Nothing dims or blocks the content, since this is a persistent nav rather than a modal — its links toggle aria-hidden and tabIndex by hand instead of relying on inert.

콘텐츠 동반 이동translateX 쌍오버레이 없음
.drawer {
  position: absolute; inset: 0 auto 0 0;
  width: 40%;
  transform: translateX(-100%);
  transition: transform $duration $easing;
}
.content {
  position: relative;
  transform: translateX(0);
  transition: transform $duration $easing;
}
.stage.is-open .drawer  { transform: translateX(0); }
.stage.is-open .content { transform: translateX(40%); }

06Mini to expand

An icon-only rail sits scaled down on the X axis around a fixed transform-origin: left center, while its inner content carries the exact inverse scale so labels never look squeezed while collapsed. Hovering or focusing the rail expands both to scaleX(1) together, and a tabindex on the rail lets :focus-within trigger the same widening from a keyboard alone — no click handler exists in this one.

scaleX 확장역스케일 보정hover·focus
@use "sass:math";

$ratio: math.div($collapsed, $expanded);

.rail {
  width: $expanded;
  transform: scaleX($ratio);
  transform-origin: left center;
  transition: transform $duration $easing;
}
.rail__inner {
  width: $expanded;
  transform: scaleX(math.div(1, $ratio));
  transform-origin: left center;
  transition: transform $duration $easing;
}
.rail:hover, .rail:focus-within {
  transform: scaleX(1);
  .rail__inner { transform: scaleX(1); }
}

07Hide on scroll

A scroll listener on the content panel next to the rail — not the window — compares scrollTop against the last reading and toggles an is-hidden class that slides the rail away with translateX(-100%). That's a deliberate split from a header that hides on window scroll: this rail watches its neighboring panel's own scroll instead, so it behaves the same way inside a document viewer no matter where the page itself is scrolled.

scroll 방향 감지translateX 은닉내부 콘텐츠 스크롤
(function () {
  var stage = document.querySelector('.stage');
  var rail = document.querySelector('.rail');
  var content = document.getElementById('content');
  var last = 0;
  content.addEventListener('scroll', function () {
    stage.classList.remove('is-demo');
    var y = content.scrollTop;
    if (y > last + 4) rail.classList.add('is-hidden');
    else if (y < last - 4) rail.classList.remove('is-hidden');
    last = y;
  });
})();

08Nested menu

Inside the same slide-in drawer as 01, a submenu expands with grid-template-rows moving from 0fr to 1fr, animating a height that plain CSS otherwise can't transition. The chevron beside it is a single element with its bottom-right border corner rotated from 45 degrees to 225, not two bars turning separately. Multi-level shopping-site navigation is the usual home for this shape.

grid-template-rows0fr→1fr쉐브론 회전
.chevron {
  border-right: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  transform: rotate(45deg);
  transition: transform $dur-base $ease-out;
}
.group.is-open .chevron { transform: rotate(225deg); }

.submenu {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows $dur-base $ease-out;
}
.submenu__inner { overflow: hidden; }
.group.is-open .submenu { grid-template-rows: 1fr; }

09Bottom tab bar

Each tap on a <button> sets indicator.style.transform directly, sliding an absolutely positioned bar under the pressed tab, and the same click swaps which tab carries aria-current="page". The icon inside the active tab brightens and grows through opacity and scale, and the label beside it brightens and lifts through opacity and translateY, on that same timing. It reads as the small-screen sibling to the other eight rather than an unrelated tab-bar UI pattern.

탭 인디케이터translateX 슬라이드하단 고정
.indicator {
  position: absolute; left: $sp-1; bottom: $sp-1;
  width: calc(25% - #{$sp-1});
  transform: translateX(0);
  transition: transform $duration $easing;
}
.tab.is-active {
  .tab__ic    { opacity: 1; transform: scale(1.15); }
  .tab__label { opacity: 1; transform: translateY(-2px); }
}

Where does a CSS sidebar drawer actually break?

The most common miss lives in 02. A native <dialog> ships centered by the browser with its own margin, border, and an implicit max-width, so skipping the inset, margin: 0, and border: 0 reset leaves a small centered box sitting in the middle of the screen instead of a left-edge panel. The second real one sits in 01's trap and its relatives: every focus() call inside it — the first link on open, the trigger on close, the wrap-around on Tab — carries { preventScroll: true } as its second argument, and that's not decoration. These demos sit inside an iframe embedded in this site's own home page grid, and without preventScroll a focus move inside that iframe was yanking the parent page's own scroll position along with it — a bug that only surfaced once the drawer showed up inside the home grid rather than on its own standalone page. Every fallback that keeps this working across the nine drawers is already wired into the archive, unlocked with 2g6qnjwn typed exactly as it reads on this line, no spaces added anywhere.

Accessibility

Every drawer that covers the page — 01, 02, 03, and 08 — locks the rest of it the same way: the background container gets inert plus aria-hidden="true" the instant it opens, so neither a screen reader nor Tab can reach anything sitting behind the panel. Items 01, 03, and 08 add a hand-built focus trap on top of that lock, moving focus to the first link on open and cycling Tab between the drawer's first and last focusable element instead of letting it escape to the page; 02 gets that identical trap, Escape key, and outside-click block from the browser for free the moment showModal() runs, since a native <dialog> owns that behavior on its own. Escape closes every panel built to be dismissed this way, including 04's sheet, and prefers-reduced-motion: reduce drops each entrance and exit transition to a near-instant swap without removing a snap or an expansion that changes real state. Item 05 isn't a modal at all, so instead of inert it toggles aria-hidden and tabIndex on its links to match whether the drawer is open.

See MDN's inert attribute reference for which browsers lock background content this way, and MDN's dialog element page for what 02 gets automatically. The @starting-style transition 02 relies on is newer than the rest of this list, so check caniuse before shipping it. More CSS animation write-ups live on the CSS category hub, and what this site does and doesn't sell is on the about page.

FAQ

Is this really CSS-only, or does it secretly need JavaScript?

Eight of the nine — every one but 06 — need a real script for the parts CSS alone can't do: toggling a class on open, trapping Tab inside an open panel, closing on Escape, reading which snap step a handle sits on, watching which way a content panel is scrolling, or swapping which tab is active. Item 06 alone runs on :hover/:focus-within with zero click handler, and 02 gets Escape and focus handling from the browser instead of a hand-written script. Nothing here claims to be pure CSS — that's why the title says minimal JS, not none.

Why does the drawer's focus() call need { preventScroll: true }?

Without it, moving focus to the first link when a drawer opens — or back to the trigger when it closes — can drag the surrounding page's scroll position along with it, since a plain .focus() scrolls its container into view by default. That mattered here because these demos run inside an iframe embedded in the site's own home grid, where a jump like that pulls the parent page's scroll instead of staying contained. Passing { preventScroll: true } to every focus() call in the trap keeps the move confined to the drawer.

Which of these nine works best on a narrow screen?

09's bottom tab bar is the one actually built for small screens — it's the mobile answer to a sidebar rather than a sidebar squeezed to fit. Among the drawers, 01's slide and 04's snap sheet both suit touch with a full-height panel or a handle to tap through steps, while 06's hover-expand rail depends on a pointer and doesn't translate to touch at all.

Enter the archive password

The password is inside this article. You will find it as you read.