05スナップガイド線
ドラッグ中は25・50・75%の位置にガイド線が出ていて、ハンドルが近づくとその線が太くなり、ぴたりとその比率に吸い付きます。
使いどころ比較画面や編集ツールなど、結局は半々や3対1といったきれいな比率に落ち着くレイアウトに
snapopacityMath.abs

<main class="stage">
<div class="sg is-demo" id="sg-root">
<div class="sg__box" id="sg-box">
<div class="sg__guides" aria-hidden="true">
<i class="sg__g sg__g--a"></i>
<i class="sg__g sg__g--b"></i>
<i class="sg__g sg__g--c"></i>
</div>
<section class="sg__pane sg__pane--l" aria-label="左のパネル">
<span class="sg__ln"></span>
<span class="sg__ln sg__ln--s"></span>
<span class="sg__ln"></span>
</section>
<div class="sg__bar" id="sg-bar" role="separator" tabindex="0"
aria-orientation="vertical" aria-label="分割比率" aria-valuemin="10" aria-valuemax="90" aria-valuenow="50">
<i class="sg__grip" aria-hidden="true"></i>
</div>
<section class="sg__pane sg__pane--r" aria-label="右のパネル">
<span class="sg__ln"></span>
<span class="sg__ln sg__ln--s"></span>
</section>
</div>
<p class="sg__cap">25・50・75%付近で磁石のように吸い付く</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-instant;
$easing: $ease-spring;
$color: $subject-mint;
@include stage($stage-ink);
@property --split {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
.sg {
--split: 50%;
width: min(304px, 88vw);
display: flex;
flex-direction: column;
gap: $sp-2;
}
.sg__box {
position: relative;
display: grid;
grid-template-columns: var(--split) 10px 1fr;
height: 124px;
border-radius: $r-card;
overflow: hidden;
background: rgba(255, 255, 255, .05);
}
.sg__pane {
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: center;
gap: $sp-2;
padding: $sp-3;
overflow: hidden;
}
.sg__ln {
flex: 0 0 auto;
height: 8px;
border-radius: $r-xs;
background: rgba(255, 247, 230, .38);
}
.sg__ln--s { width: 56%; }
.sg__pane--l { background: rgba(76, 212, 166, .2); }
.sg__pane--r { background: rgba(185, 160, 255, .18); }
.sg__guides {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 2;
opacity: 0;
transition: opacity $dur-quick $easing;
}
.sg.is-drag .sg__guides { opacity: 1; }
.sg__g {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
transform: translateX(-1px);
background: rgba(255, 247, 230, .34);
}
.sg__g--a { left: 25%; }
.sg__g--b { left: 50%; }
.sg__g--c { left: 75%; }
.sg__bar {
position: relative;
z-index: 3;
display: grid;
place-items: center;
background: rgba(255, 247, 230, .18);
cursor: col-resize;
touch-action: none;
transition: background $duration $easing;
}
.sg__grip {
display: block;
width: 2px;
height: 28px;
border-radius: $r-pill;
background: $subject-cream;
}
.sg__bar:hover,
.sg.is-drag .sg__bar { background: $color; }
.sg.is-snapped .sg__bar { background: $color; }
.sg__bar:focus-visible { outline: 2px solid $subject-cream; outline-offset: -2px; }
.sg__cap {
margin: 0;
font-size: 11px;
line-height: 14px;
font-weight: 600;
color: rgba(255, 247, 230, .58);
text-align: center;
}
// ── 자동 루프: 안내선이 켜지고, 손잡이가 25 → 75 로 가며 두 번 달라붙는다 ──
.sg.is-demo { animation: sg-move $dur-loop $ease-out infinite; }
@keyframes sg-move {
0%, 6% { --split: 50%; }
22% { --split: 30%; }
26%, 40% { --split: 25%; }
60% { --split: 70%; }
64%, 78% { --split: 75%; }
88%, 100% { --split: 50%; }
}
.sg.is-demo .sg__guides { animation: sg-guides $dur-loop steps(1, end) infinite; }
@keyframes sg-guides {
0%, 7% { opacity: 0; }
8%, 84% { opacity: 1; }
85%, 100% { opacity: 0; }
}
// 달라붙은 구간에만 굵어지는 안내선 — 컷으로 바뀐다
.sg.is-demo .sg__g--a { animation: sg-hit-a $dur-loop steps(1, end) infinite; }
.sg.is-demo .sg__g--c { animation: sg-hit-c $dur-loop steps(1, end) infinite; }
@keyframes sg-hit-a {
0%, 25% { width: 2px; background: rgba(255, 247, 230, .34); }
26%, 40% { width: 4px; background: $subject-mint; }
41%, 100% { width: 2px; background: rgba(255, 247, 230, .34); }
}
@keyframes sg-hit-c {
0%, 63% { width: 2px; background: rgba(255, 247, 230, .34); }
64%, 78% { width: 4px; background: $subject-mint; }
79%, 100% { width: 2px; background: rgba(255, 247, 230, .34); }
}
.sg.is-demo .sg__bar { animation: sg-hot $dur-loop steps(1, end) infinite; }
@keyframes sg-hot {
0%, 25% { background: rgba(255, 247, 230, .18); }
26%, 40% { background: $subject-mint; }
41%, 63% { background: rgba(255, 247, 230, .18); }
64%, 78% { background: $subject-mint; }
79%, 100% { background: rgba(255, 247, 230, .18); }
}
@media (prefers-reduced-motion: reduce) {
.sg.is-demo,
.sg.is-demo .sg__guides,
.sg.is-demo .sg__g--a,
.sg.is-demo .sg__g--c,
.sg.is-demo .sg__bar { animation: none !important; }
.sg__guides, .sg__bar { transition: none; }
}
@media (max-width: 340px) {
.sg__box { height: 108px; }
.sg__grip { height: 24px; }
}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);
}
@property --split {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
.sg {
--split: 50%;
width: min(304px, 88vw);
display: flex;
flex-direction: column;
gap: 8px;
}
.sg__box {
position: relative;
display: grid;
grid-template-columns: var(--split) 10px 1fr;
height: 124px;
border-radius: 16px;
overflow: hidden;
background: rgba(255, 255, 255, 0.05);
}
.sg__pane {
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: center;
gap: 8px;
padding: 12px;
overflow: hidden;
}
.sg__ln {
flex: 0 0 auto;
height: 8px;
border-radius: 4px;
background: rgba(255, 247, 230, 0.38);
}
.sg__ln--s {
width: 56%;
}
.sg__pane--l {
background: rgba(76, 212, 166, 0.2);
}
.sg__pane--r {
background: rgba(185, 160, 255, 0.18);
}
.sg__guides {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 2;
opacity: 0;
transition: opacity 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sg.is-drag .sg__guides {
opacity: 1;
}
.sg__g {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
transform: translateX(-1px);
background: rgba(255, 247, 230, 0.34);
}
.sg__g--a {
left: 25%;
}
.sg__g--b {
left: 50%;
}
.sg__g--c {
left: 75%;
}
.sg__bar {
position: relative;
z-index: 3;
display: grid;
place-items: center;
background: rgba(255, 247, 230, 0.18);
cursor: col-resize;
touch-action: none;
transition: background 140ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sg__grip {
display: block;
width: 2px;
height: 28px;
border-radius: 999px;
background: #fff7e6;
}
.sg__bar:hover,
.sg.is-drag .sg__bar {
background: #4cd4a6;
}
.sg.is-snapped .sg__bar {
background: #4cd4a6;
}
.sg__bar:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: -2px;
}
.sg__cap {
margin: 0;
font-size: 11px;
line-height: 14px;
font-weight: 600;
color: rgba(255, 247, 230, 0.58);
text-align: center;
}
.sg.is-demo {
animation: sg-move 2s ease-out infinite;
}
@keyframes sg-move {
0%, 6% {
--split: 50%;
}
22% {
--split: 30%;
}
26%, 40% {
--split: 25%;
}
60% {
--split: 70%;
}
64%, 78% {
--split: 75%;
}
88%, 100% {
--split: 50%;
}
}
.sg.is-demo .sg__guides {
animation: sg-guides 2s steps(1, end) infinite;
}
@keyframes sg-guides {
0%, 7% {
opacity: 0;
}
8%, 84% {
opacity: 1;
}
85%, 100% {
opacity: 0;
}
}
.sg.is-demo .sg__g--a {
animation: sg-hit-a 2s steps(1, end) infinite;
}
.sg.is-demo .sg__g--c {
animation: sg-hit-c 2s steps(1, end) infinite;
}
@keyframes sg-hit-a {
0%, 25% {
width: 2px;
background: rgba(255, 247, 230, 0.34);
}
26%, 40% {
width: 4px;
background: #4cd4a6;
}
41%, 100% {
width: 2px;
background: rgba(255, 247, 230, 0.34);
}
}
@keyframes sg-hit-c {
0%, 63% {
width: 2px;
background: rgba(255, 247, 230, 0.34);
}
64%, 78% {
width: 4px;
background: #4cd4a6;
}
79%, 100% {
width: 2px;
background: rgba(255, 247, 230, 0.34);
}
}
.sg.is-demo .sg__bar {
animation: sg-hot 2s steps(1, end) infinite;
}
@keyframes sg-hot {
0%, 25% {
background: rgba(255, 247, 230, 0.18);
}
26%, 40% {
background: #4cd4a6;
}
41%, 63% {
background: rgba(255, 247, 230, 0.18);
}
64%, 78% {
background: #4cd4a6;
}
79%, 100% {
background: rgba(255, 247, 230, 0.18);
}
}
@media (prefers-reduced-motion: reduce) {
.sg.is-demo,
.sg.is-demo .sg__guides,
.sg.is-demo .sg__g--a,
.sg.is-demo .sg__g--c,
.sg.is-demo .sg__bar {
animation: none !important;
}
.sg__guides, .sg__bar {
transition: none;
}
}
@media (max-width: 340px) {
.sg__box {
height: 108px;
}
.sg__grip {
height: 24px;
}
}// 실제 동작: 안내선 세 곳에서 6% 안으로 들어오면 그 비율로 스냅. Math.abs 로 거리만 본다.
var root = document.getElementById('sg-root');
var box = document.getElementById('sg-box');
var bar = document.getElementById('sg-bar');
var STOPS = [25, 50, 75], NEAR = 6, dragging = false;
function snap(pct) {
var best = pct;
for (var i = 0; i < STOPS.length; i++) {
if (Math.abs(pct - STOPS[i]) < NEAR) best = STOPS[i];
}
return Math.max(10, Math.min(best, 90));
}
function set(pct) {
var v = snap(pct);
root.style.setProperty('--split', v + '%');
bar.setAttribute('aria-valuenow', String(Math.round(v)));
root.classList.toggle('is-snapped', STOPS.indexOf(v) >= 0);
}
bar.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
dragging = true;
root.classList.add('is-drag');
bar.setPointerCapture(e.pointerId);
e.preventDefault();
});
bar.addEventListener('pointermove', function (e) {
if (!dragging) return;
set((e.clientX - box.getBoundingClientRect().left) / box.clientWidth * 100);
});
function drop() { dragging = false; root.classList.remove('is-drag'); }
bar.addEventListener('pointerup', drop);
bar.addEventListener('pointercancel', drop);
bar.addEventListener('keydown', function (e) {
var d = e.key === 'ArrowLeft' ? -5 : e.key === 'ArrowRight' ? 5 : 0;
if (!d) return;
root.classList.remove('is-demo');
set(Number(bar.getAttribute('aria-valuenow')) + d);
bar.focus({ preventScroll: true });
e.preventDefault();
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SnapGuideSplitter.module.scss";
export type SnapGuideSplitterProps = {
/** 자석이 되는 비율들 */
stops?: number[];
/** 몇 % 안에 들어오면 붙는가 */
near?: number;
duration?: number;
easing?: string;
color?: string;
};
type PEvt = { clientX: number; pointerId: number; preventDefault(): void; currentTarget: { setPointerCapture(id: number): void } };
type KeyEvt = { key: string; preventDefault(): void };
type Vars = CSSProperties & { "--split"?: string; "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function SnapGuideSplitter({
stops = [25, 50, 75],
near = 6,
duration = 140,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#4cd4a6",
}: SnapGuideSplitterProps) {
const [split, setSplit] = useState(50);
const [drag, setDrag] = useState(false);
const boxRef = useRef<HTMLDivElement>(null);
function snap(pct: number): number {
let best = pct;
for (let i = 0; i < stops.length; i += 1) {
if (Math.abs(pct - stops[i]) < near) best = stops[i];
}
return Math.max(10, Math.min(best, 90));
}
function set(pct: number): void { setSplit(snap(pct)); }
function onDown(e: PEvt): void {
setDrag(true);
e.currentTarget.setPointerCapture(e.pointerId);
e.preventDefault();
}
function onMove(e: PEvt): void {
const box = boxRef.current;
if (!drag || !box) return;
set(((e.clientX - box.getBoundingClientRect().left) / box.clientWidth) * 100);
}
function onUp(): void { setDrag(false); }
function onKey(e: KeyEvt): void {
const d = e.key === "ArrowLeft" ? -5 : e.key === "ArrowRight" ? 5 : 0;
if (!d) return;
set(split + d);
e.preventDefault();
}
const snapped = stops.indexOf(split) >= 0;
const vars: Vars = {
"--split": `${split}%`,
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
const cls = [styles.sg, drag ? styles.isDrag : "", snapped ? styles.isSnapped : ""].join(" ").trim();
return (
<div className={cls} style={vars}>
<div className={styles.box} ref={boxRef}>
<div className={styles.guides} aria-hidden="true">
{stops.map((s) => (
<i key={s} className={styles.g} style={{ left: `${s}%` }} />
))}
</div>
<section className={`${styles.pane} ${styles.paneL}`} aria-label="Left pane">
<span className={styles.ln} />
<span className={`${styles.ln} ${styles.lnShort}`} />
<span className={styles.ln} />
</section>
<div
className={styles.bar}
role="separator"
tabIndex={0}
aria-orientation="vertical"
aria-label="Split ratio"
aria-valuemin={10}
aria-valuemax={90}
aria-valuenow={Math.round(split)}
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={onUp}
onKeyDown={onKey}
>
<i className={styles.grip} aria-hidden="true" />
</div>
<section className={`${styles.pane} ${styles.paneR}`} aria-label="Right pane">
<span className={styles.ln} />
<span className={`${styles.ln} ${styles.lnShort}`} />
</section>
</div>
<p className={styles.cap}>It snaps at 25, 50 and 75 percent</p>
</div>
);
}05 スナップガイド線 — 計測 累積面積 14.64% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · 画面分割 css レイアウト9選 — ドラッグで幅が変わる
このデモが属する記事
画面分割 css レイアウト9選 — ドラッグで幅が変わる画面分割 css とは、1つの画面を2つ以上の枠に区切り、その境界線をつかんで引くと各枠の大きさが変わる組み方です。9つのうち8つは境界線1本を動かすだけで隣の枠が縮み、1つは箱が狭くなった瞬間に左右が上下へ入れ替わります。 この記事のデモをすべて見る


