01Trượt từ trái
Panel trượt vào từ ngoài màn hình bên trái qua translateX, trong khi overlay làm tối nội dung phía sau.
Dùng choSidebar điều hướng bên trái trong dashboard và trang thương mại điện tử
translateX(-100%)포커스 트랩inert

<main class="stage is-demo">
<div class="page" id="page">
<button class="trigger" type="button" aria-haspopup="dialog" aria-controls="drawer1">Mở 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="#">Trang chủ</a>
<a class="drawer__link" href="#">Sản phẩm</a>
<a class="drawer__link" href="#">Hỗ trợ</a>
<button class="drawer__close" type="button">Đóng</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 Trượt từ trái — Diện tích đo được 48.566% · khung hình chuyển động 9/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · 9 hiệu ứng CSS Sidebar Animation - JS tối thiểu
Bài viết chứa demo này
9 hiệu ứng CSS Sidebar Animation - JS tối thiểuCSS sidebar animation là cách một drawer đóng mở bằng transform và opacity, còn JavaScript chỉ lo phần bắt buộc: bật trạ Xem tất cả demo trong bài này


