06ミニ→拡張
アイコンだけの細いレールが hover・focus で scaleX により広がり、内側の文字は逆スケールで潰れない。
使いどころアイコンだけのダッシュボードサイドバー
scaleX 확장역스케일 보정hover·focus

<main class="stage is-demo">
<nav class="rail" tabindex="0" aria-label="サイドナビゲーション">
<div class="rail__inner">
<a class="rail__item" href="#"><span class="rail__ic" aria-hidden="true"></span><span class="rail__label">ホーム</span></a>
<a class="rail__item" href="#"><span class="rail__ic" aria-hidden="true"></span><span class="rail__label">検索</span></a>
<a class="rail__item" href="#"><span class="rail__ic" aria-hidden="true"></span><span class="rail__label">設定</span></a>
</div>
</nav>
</main>@use "sass:math";
@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base; // 300ms — 넓어지는 시간
$easing: $ease-out;
$color: $stage-yellow; // 활성 아이콘 강조색(배경 대비용, 아래서 재사용)
// ── 데모 무대 ───────────────────────────────────────────
@include stage($stage-yellow);
$collapsed: 56px;
$expanded: 200px;
$ratio: math.div($collapsed, $expanded);
.rail {
position: relative;
overflow: hidden;
width: $expanded;
height: min(50vw, 150px);
border-radius: $r-card;
background: $stage-ink;
transform: scaleX($ratio);
transform-origin: left center; // 규칙8: 레일 좌변 scaleX 접힘·펼침
transition: transform $duration $easing;
cursor: pointer;
}
// 안쪽은 바깥의 scaleX 를 역으로 상쇄해, 글자·아이콘이 눌리지 않는다
.rail__inner {
width: $expanded;
height: 100%;
box-sizing: border-box;
display: flex; flex-direction: column; gap: $sp-2;
padding: $sp-4 $sp-3;
transform: scaleX(math.div(1, $ratio));
transform-origin: left center; // 규칙8: 역상쇄 scaleX 좌변 축
transition: transform $duration $easing;
}
.rail__item {
display: flex; align-items: center; gap: $sp-3;
padding: $sp-2;
border-radius: $r-sm;
color: $stage-paper; text-decoration: none;
&:hover, &:focus-visible { background: rgba($stage-paper, .12); }
}
.rail__ic {
flex: none;
width: $sp-4; height: $sp-4;
border-radius: 50%;
background: $stage-yellow;
}
.rail__label {
font-weight: 500;
font-size: 13px;
line-height: 1;
font-family: inherit;
white-space: nowrap;
opacity: .9;
}
.rail:hover, .rail:focus-within {
transform: scaleX(1);
.rail__inner { transform: scaleX(1); }
}
// 데모 루프: 좁음 → 넓음 → 좁음, 안팎이 반대 배율로 같이 애니메이션
.stage.is-demo .rail { animation: rail-loop $dur-loop $easing infinite; }
.stage.is-demo .rail__inner { animation: rail-inner-loop $dur-loop $easing infinite; }
@keyframes rail-loop {
0%, 100% { transform: scaleX(#{$ratio}); }
30%, 75% { transform: scaleX(1); }
}
@keyframes rail-inner-loop {
0%, 100% { transform: scaleX(#{math.div(1, $ratio)}); }
30%, 75% { transform: scaleX(1); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .rail, .stage.is-demo .rail__inner { animation: none; }
.rail, .rail__inner { 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: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rail {
position: relative;
overflow: hidden;
width: 200px;
height: min(50vw, 150px);
border-radius: 16px;
background: #17141a;
transform: scaleX(0.28);
transform-origin: left center;
transition: transform 300ms ease-out;
cursor: pointer;
}
.rail__inner {
width: 200px;
height: 100%;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px 12px;
transform: scaleX(3.5714285714);
transform-origin: left center;
transition: transform 300ms ease-out;
}
.rail__item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px;
border-radius: 8px;
color: #ffffff;
text-decoration: none;
}
.rail__item:hover, .rail__item:focus-visible {
background: rgba(255, 255, 255, 0.12);
}
.rail__ic {
flex: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: #ffd23f;
}
.rail__label {
font-weight: 500;
font-size: 13px;
line-height: 1;
font-family: inherit;
white-space: nowrap;
opacity: 0.9;
}
.rail:hover, .rail:focus-within {
transform: scaleX(1);
}
.rail:hover .rail__inner, .rail:focus-within .rail__inner {
transform: scaleX(1);
}
.stage.is-demo .rail {
animation: rail-loop 2s ease-out infinite;
}
.stage.is-demo .rail__inner {
animation: rail-inner-loop 2s ease-out infinite;
}
@keyframes rail-loop {
0%, 100% {
transform: scaleX(0.28);
}
30%, 75% {
transform: scaleX(1);
}
}
@keyframes rail-inner-loop {
0%, 100% {
transform: scaleX(3.5714285714);
}
30%, 75% {
transform: scaleX(1);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .rail, .stage.is-demo .rail__inner {
animation: none;
}
.rail, .rail__inner {
transition-duration: 1ms;
}
}// 실제 hover·focus 는 CSS 만으로 동작한다. 이 한 줄은 데모 루프를 실제 상호작용으로 넘겨주는 역할뿐이다.
document.querySelector('.rail').addEventListener('pointerenter', function () {
document.querySelector('.stage').classList.remove('is-demo');
});import type { CSSProperties } from "react";
import styles from "./MiniExpandRail.module.scss";
export type MiniExpandRailProps = {
duration?: number;
easing?: string;
color?: string;
};
const ITEMS = ["홈", "검색", "설정"];
export default function MiniExpandRail({
duration = 300,
easing = "ease-out",
color = "#ffd23f",
}: MiniExpandRailProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.stage} style={vars}>
<nav className={styles.rail} tabIndex={0} aria-label="사이드 내비게이션">
<div className={styles.inner}>
{ITEMS.map((label) => (
<a key={label} className={styles.item} href="#">
<span className={styles.ic} aria-hidden="true" />
<span className={styles.label}>{label}</span>
</a>
))}
</div>
</nav>
</div>
);
}06 ミニ→拡張 — 計測 累積面積 16.075% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · css ドロワー メニュー スライド9選 — JS最小・CSSだけ
このデモが属する記事
css ドロワー メニュー スライド9選 — JS最小・CSSだけcss ドロワーは、画面の端に隠れたパネルがトリガー操作で滑り出したり、位置を変えずにその場で現れたりするナビゲーションUIです。この記事はtransformとopacityを軸に9種類の開き方を集め、うち01・03・08の3つは実際にin この記事のデモをすべて見る


