07行が変わっても先頭に残らない縦の区切り線
項目の間だけに縦線を入れ、行が変わって新しい行の先頭に来た項目では線を外して、行の始まりをすっきりさせる。
使いどころメニューやタグの間の縦のcss 区切り線
SEP_GAPSEP_H_PX

<main class="stage">
<div class="dv is-demo" id="dvRoot">
<div class="dv__card" id="dvCard">
<ul class="dv__list" id="dvList">
<li class="dv__item">ホーム</li>
<li class="dv__item">デザイン</li>
<li class="dv__item">コード</li>
<li class="dv__item">サンプル</li>
<li class="dv__item">レビュー</li>
<li class="dv__item">お問い合わせ</li>
<li class="dv__item">もっと見る</li>
</ul>
</div>
<label class="dv__ctrl">
<span>幅</span>
<input type="range" id="dvRange" min="150" max="336" value="336">
<span class="dv__meta"><span class="dv__tag">ROWS</span> <b id="dvRows">1</b></span>
</label>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 항목 앞 세로 선은 줄 맨 앞 항목(is-first)에서만 JS 판정으로 숨긴다.
$sep-gap: 12px;
$sep-h: 20px;
@include stage($stage-ink);
.dv {
width: min(100%, 360px);
display: grid;
gap: $sp-2;
justify-items: center;
}
.dv__card {
box-sizing: border-box;
width: 336px;
max-width: 100%;
min-height: 96px;
padding: $sp-4;
display: grid;
align-content: center;
border-radius: $r-card;
background: $tick-panel;
}
.dv__list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-wrap: wrap;
gap: $sp-3 0;
}
.dv__item {
position: relative;
padding: 0 var(--sep-gap, #{$sep-gap});
color: $stage-paper;
font-size: 14px;
font-weight: 700;
line-height: 24px;
}
.dv__item::before {
content: "";
position: absolute;
left: 0;
top: 50%;
width: 2px;
height: var(--sep-h, #{$sep-h});
border-radius: $r-pill;
background: $subject-sky;
transform: translateY(-50%);
}
.dv__item.is-first { padding-left: 0; }
.dv__item.is-first::before { opacity: 0; }
.dv__ctrl {
box-sizing: border-box;
width: 100%;
display: flex;
align-items: center;
gap: $sp-3;
font-size: 12px;
line-height: 16px;
color: rgba($stage-paper, .78);
}
.dv__ctrl input { flex: 1; min-width: 0; accent-color: $subject-sky; }
.dv__meta { display: flex; align-items: baseline; gap: $sp-2; }
.dv__tag { font-weight: 700; letter-spacing: .08em; }
.dv__meta b { min-width: 2ch; text-align: right; font-variant-numeric: tabular-nums; color: $stage-paper; }
@media (prefers-reduced-motion: reduce) {
.dv__item::before { transition: none; }
}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);
}
.dv {
width: min(100%, 360px);
display: grid;
gap: 8px;
justify-items: center;
}
.dv__card {
box-sizing: border-box;
width: 336px;
max-width: 100%;
min-height: 96px;
padding: 16px;
display: grid;
align-content: center;
border-radius: 16px;
background: #231f28;
}
.dv__list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-wrap: wrap;
gap: 12px 0;
}
.dv__item {
position: relative;
padding: 0 var(--sep-gap, 12px);
color: #ffffff;
font-size: 14px;
font-weight: 700;
line-height: 24px;
}
.dv__item::before {
content: "";
position: absolute;
left: 0;
top: 50%;
width: 2px;
height: var(--sep-h, 20px);
border-radius: 999px;
background: #6ec8ff;
transform: translateY(-50%);
}
.dv__item.is-first {
padding-left: 0;
}
.dv__item.is-first::before {
opacity: 0;
}
.dv__ctrl {
box-sizing: border-box;
width: 100%;
display: flex;
align-items: center;
gap: 12px;
font-size: 12px;
line-height: 16px;
color: rgba(255, 255, 255, 0.78);
}
.dv__ctrl input {
flex: 1;
min-width: 0;
accent-color: #6ec8ff;
}
.dv__meta {
display: flex;
align-items: baseline;
gap: 8px;
}
.dv__tag {
font-weight: 700;
letter-spacing: 0.08em;
}
.dv__meta b {
min-width: 2ch;
text-align: right;
font-variant-numeric: tabular-nums;
color: #ffffff;
}
@media (prefers-reduced-motion: reduce) {
.dv__item::before {
transition: none;
}
}(function () {
// 기법: 항목마다 앞에 세로 선(::before)을 두고, 줄이 바뀐 항목은 JS 가 offsetTop 을 앞 항목과 비교해 is-first 를 붙여 선을 숨긴다.
// 폭이 바뀔 때마다 다시 판정하므로 어느 폭에서도 줄 맨 앞에 선이 남지 않는다. (CSS 만으로는 줄 시작을 알 수 없다.)
var SEP_GAP = 12;
var SEP_H_PX = 20;
var CYCLE_MS = 2000;
var NARROW_PX = 150;
var WIDE_PX = 336;
var root = document.getElementById('dvRoot');
var card = document.getElementById('dvCard');
var list = document.getElementById('dvList');
var range = document.getElementById('dvRange');
var rowsEl = document.getElementById('dvRows');
var items = Array.prototype.slice.call(list.children);
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function mark() {
var rows = 0;
var lastTop = null;
items.forEach(function (li) {
var top = li.offsetTop;
var first = lastTop === null || top !== lastTop;
if (first) rows += 1;
li.classList.toggle('is-first', first);
lastTop = top;
});
rowsEl.textContent = String(rows);
}
function setWidth(px) {
card.style.width = px.toFixed(1) + 'px';
range.value = String(Math.round(px));
mark();
}
function stopDemo() { root.classList.remove('is-demo'); }
range.addEventListener('input', function () {
stopDemo();
setWidth(Number(range.value));
});
var t0 = null;
function tick(ts) {
if (t0 === null) t0 = ts;
if (root.classList.contains('is-demo')) {
var phase = ((ts - t0) % CYCLE_MS) / CYCLE_MS;
var wave = 0.5 + 0.5 * Math.cos(phase * Math.PI * 2);
setWidth(NARROW_PX + (WIDE_PX - NARROW_PX) * wave);
}
requestAnimationFrame(tick);
}
card.style.setProperty('--sep-gap', SEP_GAP + 'px');
card.style.setProperty('--sep-h', SEP_H_PX + 'px');
setWidth(WIDE_PX);
if (reduce) {
stopDemo();
} else {
requestAnimationFrame(tick);
}
})();// WrapSeparator — vanilla 판(07-divl-vertical-wrap-aware-separator) 의 React 포트.
// 항목 앞 세로 선은 줄 맨 앞 항목(is-first)에서만 JS 가 offsetTop 비교로 숨긴다.
import { useEffect, useRef } from "react";
import styles from "./WrapSeparator.module.scss";
type Box<T> = { current: T };
const SEP_GAP = 12;
const SEP_H_PX = 20;
const CYCLE_MS = 2000;
const NARROW_PX = 150;
const WIDE_PX = 336;
const LABELS: string[] = ["홈", "디자인", "코드", "예제", "후기", "문의", "더보기"];
export default function WrapSeparator() {
const cardRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const listRef = useRef<HTMLUListElement | null>(null) as Box<HTMLUListElement | null>;
const rangeRef = useRef<HTMLInputElement | null>(null) as Box<HTMLInputElement | null>;
const rowsRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
useEffect(() => {
const card = cardRef.current;
const list = listRef.current;
const range = rangeRef.current;
const rowsEl = rowsRef.current;
if (!card || !list || !range || !rowsEl) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const items = Array.from(list.children) as HTMLElement[];
const firstClass = styles.first;
let demo = !reduce;
let t0: number | null = null;
let raf = 0;
const mark = (): void => {
let rows = 0;
let lastTop: number | null = null;
items.forEach((li) => {
const top = li.offsetTop;
const first = lastTop === null || top !== lastTop;
if (first) rows += 1;
li.classList.toggle(firstClass, first);
lastTop = top;
});
rowsEl.textContent = String(rows);
};
const setWidth = (px: number): void => {
card.style.width = px.toFixed(1) + "px";
range.value = String(Math.round(px));
mark();
};
const onInput = (): void => {
demo = false;
setWidth(Number(range.value));
};
range.addEventListener("input", onInput);
function tick(ts: number): void {
if (t0 === null) t0 = ts;
if (demo) {
const phase = ((ts - t0) % CYCLE_MS) / CYCLE_MS;
const wave = 0.5 + 0.5 * Math.cos(phase * Math.PI * 2);
setWidth(NARROW_PX + (WIDE_PX - NARROW_PX) * wave);
}
raf = requestAnimationFrame(tick);
}
card.style.setProperty("--sep-gap", SEP_GAP + "px");
card.style.setProperty("--sep-h", SEP_H_PX + "px");
setWidth(WIDE_PX);
if (!reduce) raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
range.removeEventListener("input", onInput);
};
}, []);
return (
<div className={styles.dv}>
<div className={styles.card} ref={cardRef}>
<ul className={styles.list} ref={listRef}>
{LABELS.map((label) => (
<li key={label} className={styles.item}>{label}</li>
))}
</ul>
</div>
<label className={styles.ctrl}>
<span>폭</span>
<input type="range" min={150} max={336} defaultValue={336} ref={rangeRef} />
<span className={styles.meta}>
<span className={styles.tag}>ROWS</span> <b ref={rowsRef}>1</b>
</span>
</label>
</div>
);
}07 行が変わっても先頭に残らない縦の区切り線 — 計測 累積面積 29.527% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · css 区切り線 9パターン — 両端が消えて文字の幅で線が分かれる区切りの場面
このデモが属する記事
css 区切り線 9パターン — 両端が消えて文字の幅で線が分かれる区切りの場面css 区切り線は線一本で済みそうだが、両端がぷつりと切れ、中央の文字の幅に線の長さが合わず、セクションの境目が直線で単調になる — 01番・02番・04番が一つずつ直す。このページの九つは、線の形を値で計算して描きます。 この記事のデモをすべて見る


