01Left slide drawer
The panel slides in from off-screen left via translateX while a dark overlay dims the content behind it.
Best forLeft navigation sidebars in dashboards and shopping sites
translateX(-100%)포커스 트랩inert

<main class="stage is-demo">
<div class="page" id="page">
<button class="trigger" type="button" aria-haspopup="dialog" aria-controls="drawer1">Open menu</button>
</div>
<div class="overlay"></div>
<nav class="drawer" id="drawer1" role="dialog" aria-modal="true" aria-labelledby="t1">
<h2 id="t1">Menu</h2>
<a class="drawer__link" href="#">Home</a>
<a class="drawer__link" href="#">Products</a>
<a class="drawer__link" href="#">Support</a>
<button class="drawer__close" type="button">Close</button>
</nav>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-enter; // 600ms — 왼쪽에서 밀려 들어오는 시간
$easing: $ease-spring;
$color: $subject-cream; // 트리거 버튼 강조색
// ── 데모 무대 ───────────────────────────────────────────
@include stage($stage-ink);
.stage { position: relative; }
.page { display: grid; place-items: center; }
.trigger {
border: 0; border-radius: $r-pill;
padding: $sp-3 $sp-6;
font-weight: 600;
font-size: clamp(13px, 4vmin, 16px);
line-height: 1;
font-family: inherit;
color: $stage-ink; background: $color;
cursor: pointer; -webkit-tap-highlight-color: transparent;
}
.overlay {
position: fixed; inset: 0;
background: rgba($stage-ink, .55);
opacity: 0; pointer-events: none;
transition: opacity $duration $easing;
}
.drawer {
position: fixed; left: 0; top: 0; bottom: 0; right: auto;
transform: translateX(-100%);
width: min(76vw, 240px);
box-sizing: border-box;
display: flex; flex-direction: column; gap: $sp-2;
padding: $sp-6 $sp-4;
background: $stage-paper; color: $stage-ink;
box-shadow: $shadow-raised;
pointer-events: none;
transition: transform $duration $easing;
h2 { margin: 0 0 $sp-2; font-size: clamp(13px, 4.2vmin, 17px); }
}
.drawer__link {
padding: $sp-2 $sp-1;
border-radius: $r-sm;
color: $stage-ink; text-decoration: none; font-weight: 500; font-size: 13px; line-height: 1; font-family: inherit;
&:hover, &:focus-visible { background: rgba($stage-ink, .08); }
}
.drawer__close {
margin-top: auto;
border: 0; border-radius: $r-control;
background: $stage-ink; color: $stage-paper;
padding: $sp-2 $sp-4;
font-weight: 600; font-size: 13px; line-height: 1; font-family: inherit; cursor: pointer;
}
.stage.is-open .overlay { opacity: 1; pointer-events: auto; }
.stage.is-open .drawer { transform: translateX(0); pointer-events: auto; }
// 실측: 데모 루프는 열림→머묾→닫힘을 2초 안에 반복(1번 항목과 같은 구조)
.stage.is-demo .overlay { animation: overlay-loop $dur-loop ease-in-out infinite; }
.stage.is-demo .drawer { animation: drawer-loop $dur-loop $easing infinite; }
@keyframes overlay-loop {
0%, 100% { opacity: 0; }
15%, 70% { opacity: 1; }
85% { opacity: 0; }
}
@keyframes drawer-loop {
0%, 100% { transform: translateX(-100%); }
15%, 70% { transform: translateX(0); }
85% { transform: translateX(-100%); }
}
// ── 접근성: 움직임 줄이기 설정이면 여닫힘 애니메이션만 제거 ──────────
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .overlay, .stage.is-demo .drawer { animation: none; }
.overlay, .drawer { transition-duration: 1ms; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
}
.page {
display: grid;
place-items: center;
}
.trigger {
border: 0;
border-radius: 999px;
padding: 12px 24px;
font-weight: 600;
font-size: clamp(13px, 4vmin, 16px);
line-height: 1;
font-family: inherit;
color: #17141a;
background: #fff7e6;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.overlay {
position: fixed;
inset: 0;
background: rgba(23, 20, 26, 0.55);
opacity: 0;
pointer-events: none;
transition: opacity 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.drawer {
position: fixed;
left: 0;
top: 0;
bottom: 0;
right: auto;
transform: translateX(-100%);
width: min(76vw, 240px);
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 8px;
padding: 24px 16px;
background: #ffffff;
color: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
pointer-events: none;
transition: transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.drawer h2 {
margin: 0 0 8px;
font-size: clamp(13px, 4.2vmin, 17px);
}
.drawer__link {
padding: 8px 4px;
border-radius: 8px;
color: #17141a;
text-decoration: none;
font-weight: 500;
font-size: 13px;
line-height: 1;
font-family: inherit;
}
.drawer__link:hover, .drawer__link:focus-visible {
background: rgba(23, 20, 26, 0.08);
}
.drawer__close {
margin-top: auto;
border: 0;
border-radius: 12px;
background: #17141a;
color: #ffffff;
padding: 8px 16px;
font-weight: 600;
font-size: 13px;
line-height: 1;
font-family: inherit;
cursor: pointer;
}
.stage.is-open .overlay {
opacity: 1;
pointer-events: auto;
}
.stage.is-open .drawer {
transform: translateX(0);
pointer-events: auto;
}
.stage.is-demo .overlay {
animation: overlay-loop 2s ease-in-out infinite;
}
.stage.is-demo .drawer {
animation: drawer-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes overlay-loop {
0%, 100% {
opacity: 0;
}
15%, 70% {
opacity: 1;
}
85% {
opacity: 0;
}
}
@keyframes drawer-loop {
0%, 100% {
transform: translateX(-100%);
}
15%, 70% {
transform: translateX(0);
}
85% {
transform: translateX(-100%);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .overlay, .stage.is-demo .drawer {
animation: none;
}
.overlay, .drawer {
transition-duration: 1ms;
}
}// 열림: 배경(#page)을 inert 로 막고 첫 링크로 포커스. 닫힘: inert 해제, 트리거로 포커스 복귀.
// Tab 키다운마다 포커스 가능 목록의 처음/끝을 감시해 드로어 밖으로 못 나가게 한다(포커스 트랩).
(function () {
var stage = document.querySelector('.stage');
var page = document.getElementById('page');
var trigger = document.querySelector('.trigger');
var overlay = document.querySelector('.overlay');
var drawer = document.querySelector('.drawer');
var closeBtn = document.querySelector('.drawer__close');
function focusables() { return drawer.querySelectorAll('a, button'); }
function open() {
stage.classList.remove('is-demo'); stage.classList.add('is-open');
page.setAttribute('inert', ''); page.setAttribute('aria-hidden', 'true');
focusables()[0].focus({ preventScroll: true });
}
function shut() {
stage.classList.remove('is-open');
page.removeAttribute('inert'); page.removeAttribute('aria-hidden');
trigger.focus({ preventScroll: true });
}
trigger.addEventListener('click', open);
overlay.addEventListener('click', shut);
closeBtn.addEventListener('click', shut);
document.addEventListener('keydown', function (e) {
if (!stage.classList.contains('is-open')) return;
if (e.key === 'Escape') { shut(); return; }
if (e.key !== 'Tab') return;
var f = focusables(), first = f[0], last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) { last.focus({ preventScroll: true }); e.preventDefault(); }
else if (!e.shiftKey && document.activeElement === last) { first.focus({ preventScroll: true }); e.preventDefault(); }
});
})();import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LeftSlideDrawer.module.scss";
export type LeftSlideDrawerProps = {
/** 왼쪽에서 밀려 들어오는 시간 (기본 600ms) */
duration?: number;
easing?: string;
color?: string;
};
export default function LeftSlideDrawer({
duration = 600,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: LeftSlideDrawerProps) {
const [open, setOpen] = useState(false);
const drawerRef = useRef<HTMLElement | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
if (!open) {
triggerRef.current?.focus();
return;
}
const drawer = drawerRef.current;
if (!drawer) return;
const focusables = () => Array.from(drawer.querySelectorAll<HTMLElement>("a, button"));
focusables()[0]?.focus();
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") { setOpen(false); return; }
if (e.key !== "Tab") return;
const f = focusables();
const first = f[0], last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) { last.focus(); e.preventDefault(); }
else if (!e.shiftKey && document.activeElement === last) { first.focus(); e.preventDefault(); }
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open]);
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.stage} style={vars}>
<div className={styles.page} inert={open || undefined} aria-hidden={open}>
<button ref={triggerRef} className={styles.trigger} type="button" onClick={() => setOpen(true)}>
메뉴 열기
</button>
</div>
<div className={styles.overlay} data-open={open} onClick={() => setOpen(false)} />
<nav
ref={drawerRef}
className={styles.drawer}
data-open={open}
role="dialog"
aria-modal="true"
aria-label="메뉴"
>
<h2>메뉴</h2>
<a className={styles.link} href="#">홈</a>
<a className={styles.link} href="#">제품</a>
<a className={styles.link} href="#">고객센터</a>
<button className={styles.close} type="button" onClick={() => setOpen(false)}>닫기</button>
</nav>
</div>
);
}01 Left slide drawer — Measured area 48.566% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · 9 CSS Sidebar Animations — Minimal JS
The post this demo lives in
9 CSS Sidebar Animations — Minimal JSA css sidebar animation is a panel pinned to a screen edge that slides, fades, snaps, or scales open on a trigger, and t See all demos in this post
If you like this effect - similar demos
Transitions07 Side drawer that slides in from the right9 mega menu ui patterns, link list to site map
Transitions02 Left/right slide9 CSS Page Transitions — No Library, Pure CSS
Transitions01 Slide overshoot — a cart panel whose open position value passes its target before settling backCart Drawer UI Patterns: 9 Motion Techniques