06縦横方向転換 — グループの方向の値が変わるとボタンの位置の値が実測座標の間を移動して並びが組み直される方式
方向の値が横から縦に変わると、各ボタンの位置の値が旧座標から新座標まで移動し、並びが組み直されます。
使いどころレスポンシブツールバー、サイドバーに折り畳まれるボタン群
FLIP_MS 480FLIP Invert Playforced reflow

<main class="stage">
<div class="vhm" id="vhm">
<div class="vhm__group" id="vhmGroup" role="group" aria-label="編集ツール"></div>
<button class="vhm__toggle" id="vhmToggle" type="button" aria-pressed="false">方向切替 ⤢</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html 의 flip() 이 방향 전환 전(First)·후(Last) 각 버튼의 실측 좌표 차를 구해
// transform 으로 상쇄한 뒤 되돌리는 FLIP 기법 — 레이아웃 속성 자체는 전환에만 한 번 바뀌고,
// 눈에 보이는 이동은 항상 transform 으로만 그린다.
@include stage($stage-yellow);
.vhm {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
max-width: 100%;
}
.vhm__group {
display: flex;
flex-direction: row;
gap: $sp-2;
max-width: 100%;
overflow-x: auto;
}
.vhm--vertical .vhm__group {
flex-direction: column;
overflow-x: visible;
}
.vhm__btn {
appearance: none;
border: none;
border-radius: $r-sm;
background: $subject-cream;
color: $subject-ink;
font-size: 13px;
font-weight: 700;
padding: $sp-2 $sp-4;
cursor: pointer;
white-space: nowrap;
flex: 0 0 auto;
}
.vhm__toggle {
appearance: none;
border: 1px solid rgba($subject-ink, .24);
border-radius: $r-pill;
background: transparent;
color: $subject-ink;
font-size: 12px;
font-weight: 600;
padding: $sp-1 $sp-3;
cursor: pointer;
}
@media (max-width: 340px) {
.vhm__btn { font-size: 12px; padding: $sp-2 $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.vhm__btn { transition: none !important; }
}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);
}
.vhm {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
max-width: 100%;
}
.vhm__group {
display: flex;
flex-direction: row;
gap: 8px;
max-width: 100%;
overflow-x: auto;
}
.vhm--vertical .vhm__group {
flex-direction: column;
overflow-x: visible;
}
.vhm__btn {
appearance: none;
border: none;
border-radius: 8px;
background: #fff7e6;
color: #17141a;
font-size: 13px;
font-weight: 700;
padding: 8px 16px;
cursor: pointer;
white-space: nowrap;
flex: 0 0 auto;
}
.vhm__toggle {
appearance: none;
border: 1px solid rgba(23, 20, 26, 0.24);
border-radius: 999px;
background: transparent;
color: #17141a;
font-size: 12px;
font-weight: 600;
padding: 4px 12px;
cursor: pointer;
}
@media (max-width: 340px) {
.vhm__btn {
font-size: 12px;
padding: 8px 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.vhm__btn {
transition: none !important;
}
}(function () {
var LABELS = ['太字', '斜体', '下線', '整列'];
var CYCLE_MS = 700; // 데모 자동 전환 주기 — 측정 창(2s) 안에 전환이 여러 번 들어오게 짧게 잡음
var FLIP_MS = 480;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var root = document.getElementById('vhm');
var group = document.getElementById('vhmGroup');
var toggle = document.getElementById('vhmToggle');
var isDemo = true;
var vertical = false;
var btns = LABELS.map(function (label) {
var b = document.createElement('button');
b.type = 'button';
b.className = 'vhm__btn';
b.textContent = label;
group.appendChild(b);
return b;
});
function human() {
if (isDemo) { root.classList.remove('is-demo'); isDemo = false; }
}
// 계산 겹: FLIP — 전환 전(First) 각 버튼의 실측 좌표를 재고, 전환 후(Last) 좌표와의 차이(Invert)를
// transform 으로 순간 상쇄한 뒤, 다음 프레임에 transform 을 0 으로 되돌려(Play) 자연스럽게 이어붙인다.
function flip(apply) {
var first = btns.map(function (b) { return b.getBoundingClientRect(); });
apply();
var last = btns.map(function (b) { return b.getBoundingClientRect(); });
if (reduce) return; // 계산은 유지하되 즉시 반영, 애니메이션 생략
btns.forEach(function (b, i) {
var dx = first[i].left - last[i].left;
var dy = first[i].top - last[i].top;
if (!dx && !dy) return;
b.style.transition = 'none';
b.style.transform = 'translate(' + dx + 'px,' + dy + 'px)';
// 강제 리플로우로 초기 transform 을 화면에 반영시킨 뒤에만 되돌리기 transition 이 재생된다.
b.getBoundingClientRect();
requestAnimationFrame(function () {
b.style.transition = 'transform ' + FLIP_MS + 'ms cubic-bezier(.22,.9,.32,1)';
b.style.transform = 'translate(0,0)';
});
});
}
function setOrientation(v) {
flip(function () {
vertical = v;
root.classList.toggle('vhm--vertical', vertical);
toggle.setAttribute('aria-pressed', String(vertical));
});
}
toggle.addEventListener('click', function () {
human();
setOrientation(!vertical);
});
var start = 0;
function loop(ts) {
if (!start) start = ts;
if (isDemo && !reduce) {
var phaseVertical = (ts - start) % (CYCLE_MS * 2) >= CYCLE_MS;
if (phaseVertical !== vertical) setOrientation(phaseVertical);
}
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();import { useEffect, useRef } from 'react';
import styles from './OrientMorph.module.scss';
type Box<T> = { current: T };
const LABELS = ['굵게', '기울임', '밑줄', '정렬'];
const CYCLE_MS = 700; // 데모 자동 전환 주기 — 측정 창(2s) 안에 전환이 여러 번 들어오게 짧게 잡음
const FLIP_MS = 480;
export default function OrientMorph() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const groupRef = useRef(null) as unknown as Box<HTMLDivElement>;
const toggleRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const btnRefs = useRef([]) as unknown as Box<HTMLButtonElement[]>;
useEffect(() => {
const root = rootRef.current;
const toggle = toggleRef.current;
const btns = btnRefs.current;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
let isDemo = true;
let vertical = false;
function human() {
if (isDemo) { root.classList.remove(styles.isDemo); isDemo = false; }
}
// 계산 겹: FLIP — 전환 전(First) 각 버튼의 실측 좌표를 재고, 전환 후(Last) 좌표와의 차이(Invert)를
// transform 으로 순간 상쇄한 뒤, 다음 프레임에 transform 을 0 으로 되돌려(Play) 자연스럽게 이어붙인다.
function flip(apply: () => void) {
const first = btns.map((b) => b.getBoundingClientRect());
apply();
const last = btns.map((b) => b.getBoundingClientRect());
if (reduce) return; // 계산은 유지하되 즉시 반영, 애니메이션 생략
btns.forEach((b, i) => {
const dx = first[i].left - last[i].left;
const dy = first[i].top - last[i].top;
if (!dx && !dy) return;
b.style.transition = 'none';
b.style.transform = `translate(${dx}px,${dy}px)`;
b.getBoundingClientRect();
requestAnimationFrame(() => {
b.style.transition = `transform ${FLIP_MS}ms cubic-bezier(.22,.9,.32,1)`;
b.style.transform = 'translate(0,0)';
});
});
}
function setOrientation(v: boolean) {
flip(() => {
vertical = v;
root.classList.toggle(styles.vertical, vertical);
toggle.setAttribute('aria-pressed', String(vertical));
});
}
function onToggleClick() {
human();
setOrientation(!vertical);
}
toggle.addEventListener('click', onToggleClick);
let start = 0;
let raf = 0;
function loop(ts: number) {
if (!start) start = ts;
if (isDemo && !reduce) {
const phaseVertical = (ts - start) % (CYCLE_MS * 2) >= CYCLE_MS;
if (phaseVertical !== vertical) setOrientation(phaseVertical);
}
raf = requestAnimationFrame(loop);
}
raf = requestAnimationFrame(loop);
return () => {
cancelAnimationFrame(raf);
toggle.removeEventListener('click', onToggleClick);
};
}, []);
return (
<div className={`${styles.vhm} ${styles.isDemo}`} ref={(el: HTMLDivElement | null) => { if (el) rootRef.current = el; }}>
<div
className={styles.group}
role="group"
aria-label="편집 도구"
ref={(el: HTMLDivElement | null) => { if (el) groupRef.current = el; }}
>
{LABELS.map((label, i) => (
<button
key={label}
type="button"
className={styles.btn}
ref={(el: HTMLButtonElement | null) => { if (el) btnRefs.current[i] = el; }}
>
{label}
</button>
))}
</div>
<button
type="button"
className={styles.toggle}
aria-pressed="false"
ref={(el: HTMLButtonElement | null) => { if (el) toggleRef.current = el; }}
>
방향 전환 ⤢
</button>
</div>
);
}06 縦横方向転換 — グループの方向の値が変わるとボタンの位置の値が実測座標の間を移動して並びが組み直される方式 — 計測 累積面積 15.29% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · ボタン グループ ui 9選 — 隣り合うボタンが一つに動く仕組み
このデモが属する記事
ボタン グループ ui 9選 — 隣り合うボタンが一つに動く仕組みボタン グループ ui: 9つのうち1つはアクティブボタンの値が変わるとスライド表示子の位置の値が実測座標に沿って移動し、1つはグループの幅の値が縮むと溢れたボタンがもっと見る値の中に折り畳まれ、もう1つは方向キーでフォーカスの値が移る際に この記事のデモをすべて見る


