08줄이 넘치면 「+N」으로 접는 칩 입력
한 줄에 들어갈 수 있는 폭을 칩들의 너비 합과 계속 비교하다가 넘치는 순간부터는 그 뒤 칩들을 감추고 감춘 개수를 세어 자리에 남긴 배지에 적는다.
쓰임좁은 카드 안 태그 목록·모바일 필터 칩
BADGE_RESERVEGAPrelayout

<main class="stage">
<div class="ov is-demo" id="ov">
<div class="ov__viewport" id="ovViewport">
<div class="ov__row" id="ovRow" role="list" aria-label="태그 목록, 좁아지면 접힘">
<span class="ov__chip" role="listitem">기획</span>
<span class="ov__chip" role="listitem">디자인</span>
<span class="ov__chip" role="listitem">개발</span>
<span class="ov__chip" role="listitem">QA</span>
<span class="ov__chip" role="listitem">마케팅</span>
<span class="ov__chip" role="listitem">영업</span>
<span class="ov__badge" id="ovBadge" role="listitem">+0</span>
</div>
</div>
<p class="ov__meta">폭 <b id="ovWidth">0</b>px · 접힌 태그 <b id="ovHidden">0</b>개</p>
<p class="ov__sr" role="status" aria-live="polite" id="ovLive"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: relayout() 이 뷰포트 폭을 칩 누적 너비합 + 배지 예약폭과 매 프레임(리사이즈 중) 비교해
// 넘치는 지점부터 칩을 감추고 감춘 개수를 배지에 적는다. 뷰포트는 max-width 로 좁아졌다 넓어졌다 한다.
@include stage($stage-paper);
.ov { width: min(100%, 320px); display: flex; flex-direction: column; gap: $sp-2; }
.ov__viewport {
max-width: 280px; overflow: hidden;
transition: max-width $dur-enter $ease-spring;
border: 1px solid rgba($subject-ink, .16); border-radius: $r-control;
padding: $sp-2 $sp-3; cursor: pointer;
}
.ov__viewport--narrow { max-width: 130px; }
.ov__row { display: flex; flex-wrap: nowrap; align-items: center; gap: $sp-2; }
.ov__chip {
flex: 0 0 auto;
display: inline-flex; align-items: center; height: 24px; padding: 0 $sp-3;
background: $subject-blue-deep; color: $subject-cream; border-radius: $r-pill;
font-size: 12px; font-weight: 700; white-space: nowrap;
}
.ov__badge {
flex: 0 0 auto;
display: inline-flex; align-items: center; height: 24px; padding: 0 $sp-3;
background: $subject-ink; color: $subject-cream; border-radius: $r-pill;
font-size: 12px; font-weight: 700; white-space: nowrap;
transform: scale(.4); opacity: 0;
transition: transform $dur-quick $ease-spring, opacity $dur-quick $ease-out;
}
.ov__badge--show { transform: scale(1); opacity: 1; }
.ov__meta { margin: 0; font-size: 11px; color: rgba($subject-ink, .72); }
.ov__meta b { color: $subject-ink; font-weight: 700; }
.ov__sr {
position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); margin: -1px; padding: 0; border: 0;
}
@media (max-width: 340px) {
.ov { width: 100%; }
.ov__viewport { max-width: 100%; }
.ov__viewport--narrow { max-width: 130px; }
}
@media (prefers-reduced-motion: reduce) {
.ov__viewport { transition: none; }
.ov__badge { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ov {
width: min(100%, 320px);
display: flex;
flex-direction: column;
gap: 8px;
}
.ov__viewport {
max-width: 280px;
overflow: hidden;
transition: max-width 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
border: 1px solid rgba(23, 20, 26, 0.16);
border-radius: 12px;
padding: 8px 12px;
cursor: pointer;
}
.ov__viewport--narrow {
max-width: 130px;
}
.ov__row {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 8px;
}
.ov__chip {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
height: 24px;
padding: 0 12px;
background: #0a4fe8;
color: #fff7e6;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.ov__badge {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
height: 24px;
padding: 0 12px;
background: #17141a;
color: #fff7e6;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
transform: scale(0.4);
opacity: 0;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 220ms ease-out;
}
.ov__badge--show {
transform: scale(1);
opacity: 1;
}
.ov__meta {
margin: 0;
font-size: 11px;
color: rgba(23, 20, 26, 0.72);
}
.ov__meta b {
color: #17141a;
font-weight: 700;
}
.ov__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
margin: -1px;
padding: 0;
border: 0;
}
@media (max-width: 340px) {
.ov {
width: 100%;
}
.ov__viewport {
max-width: 100%;
}
.ov__viewport--narrow {
max-width: 130px;
}
}
@media (prefers-reduced-motion: reduce) {
.ov__viewport {
transition: none;
}
.ov__badge {
transition: none;
}
}(function () {
var BADGE_RESERVE = 34; // 계산 겹 상수: 배지 자리 예약폭(px) — 실측 렌더폭 기준 어림
var GAP = 8;
var WIDE_MS = 900, NARROW_MS = 900, PAUSE_MS = 500;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var root = document.getElementById('ov');
var viewport = document.getElementById('ovViewport');
var row = document.getElementById('ovRow');
var badge = document.getElementById('ovBadge');
var widthEl = document.getElementById('ovWidth');
var hiddenEl = document.getElementById('ovHidden');
var live = document.getElementById('ovLive');
function human() { if (root.classList.contains('is-demo')) root.classList.remove('is-demo'); }
// 계산 겹: 뷰포트 폭을 칩들의 누적 너비합과 계속 비교해, 다음 칩(+배지 자리)이 넘치는 지점부터 감춘다.
function relayout() {
var chips = Array.prototype.slice.call(row.querySelectorAll('.ov__chip'));
var containerWidth = viewport.getBoundingClientRect().width;
var sum = 0, shown = 0;
for (var i = 0; i < chips.length; i++) {
chips[i].style.display = '';
}
for (i = 0; i < chips.length; i++) {
var w = chips[i].getBoundingClientRect().width;
var addGap = shown > 0 ? GAP : 0;
var withThis = sum + addGap + w;
var remaining = chips.length - (i + 1);
var reserve = remaining > 0 ? GAP + BADGE_RESERVE : 0;
if (withThis + reserve <= containerWidth) { sum = withThis; shown++; }
else { break; }
}
for (var j = shown; j < chips.length; j++) { chips[j].style.display = 'none'; }
var hidden = chips.length - shown;
if (hidden > 0) {
badge.textContent = '+' + hidden;
badge.classList.add('ov__badge--show');
} else {
badge.classList.remove('ov__badge--show');
}
widthEl.textContent = Math.round(containerWidth);
hiddenEl.textContent = hidden;
live.textContent = hidden > 0 ? hidden + '개 태그 접힘' : '전체 표시';
}
var ro = window.ResizeObserver ? new ResizeObserver(function () { relayout(); }) : null;
if (ro) ro.observe(viewport);
var timer = 0, rafId = 0;
function schedule(fn, ms) { timer = window.setTimeout(fn, ms); }
function pollDuring(ms, cb) {
var t0 = null;
function step(ts) {
if (!root.classList.contains('is-demo')) return;
if (t0 === null) t0 = ts;
relayout();
if (ts - t0 < ms) { rafId = requestAnimationFrame(step); } else { relayout(); cb(); }
}
rafId = requestAnimationFrame(step);
}
function loop() {
if (!root.classList.contains('is-demo')) return;
viewport.classList.add('ov__viewport--narrow');
pollDuring(NARROW_MS, function () {
schedule(function () {
if (!root.classList.contains('is-demo')) return;
viewport.classList.remove('ov__viewport--narrow');
pollDuring(WIDE_MS, function () { schedule(loop, PAUSE_MS); });
}, PAUSE_MS);
});
}
viewport.addEventListener('click', function () { human(); viewport.classList.toggle('ov__viewport--narrow'); relayout(); });
window.addEventListener('resize', relayout);
relayout();
if (!reduce) {
schedule(loop, 300);
} else {
root.classList.remove('is-demo');
}
})();// TagOverflowRowCollapse — vanilla 판(run/397/demo/08-tag-input-overflow-row-collapse/index.html) 의 React 포트.
// 계산 겹(뷰포트 폭 대 칩 누적 너비합 + 배지 예약폭 비교)은 vanilla 와 같은 값.
import { useEffect, useRef, useState } from "react";
import styles from "./TagOverflowRowCollapse.module.scss";
type Box<T> = { current: T };
const BADGE_RESERVE = 34; // 계산 겹 상수: 배지 자리 예약폭(px)
const GAP = 8;
const WORDS = ["기획", "디자인", "개발", "QA", "마케팅", "영업"];
const WIDE_MS = 900, NARROW_MS = 900, PAUSE_MS = 500;
export default function TagOverflowRowCollapse() {
const viewportRef = useRef(null) as unknown as Box<HTMLDivElement>;
const rowRef = useRef(null) as unknown as Box<HTMLDivElement>;
const isDemoRef = useRef(true) as unknown as Box<boolean>;
const timerRef = useRef(0) as unknown as Box<number>;
const rafRef = useRef(0) as unknown as Box<number>;
const [isDemo, setIsDemo] = useState(true);
const [narrow, setNarrow] = useState(false);
const [width, setWidth] = useState(0);
const [hidden, setHidden] = useState(0);
const [visibleCount, setVisibleCount] = useState(WORDS.length);
const [live, setLive] = useState("");
function human() {
if (isDemoRef.current) { isDemoRef.current = false; setIsDemo(false); }
}
function relayout() {
const viewport = viewportRef.current;
const row = rowRef.current;
if (!viewport || !row) return;
const chips = Array.prototype.slice.call(row.querySelectorAll("[data-chip]")) as HTMLElement[];
const containerWidth = viewport.getBoundingClientRect().width;
let sum = 0, shown = 0;
for (let i = 0; i < chips.length; i++) {
const w = chips[i].getBoundingClientRect().width;
const addGap = shown > 0 ? GAP : 0;
const withThis = sum + addGap + w;
const remaining = chips.length - (i + 1);
const reserve = remaining > 0 ? GAP + BADGE_RESERVE : 0;
if (withThis + reserve <= containerWidth) { sum = withThis; shown++; } else break;
}
setVisibleCount(shown);
setWidth(Math.round(containerWidth));
setHidden(chips.length - shown);
setLive(chips.length - shown > 0 ? (chips.length - shown) + "개 태그 접힘" : "전체 표시");
}
useEffect(() => {
relayout();
const ro = window.ResizeObserver ? new ResizeObserver(() => relayout()) : null;
if (ro && viewportRef.current) ro.observe(viewportRef.current);
window.addEventListener("resize", relayout);
return () => { if (ro) ro.disconnect(); window.removeEventListener("resize", relayout); };
}, [narrow]);
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function pollDuring(ms: number, cb: () => void) {
let t0 = 0;
function step(ts: number) {
if (!isDemoRef.current) return;
if (!t0) t0 = ts;
relayout();
if (ts - t0 < ms) { rafRef.current = requestAnimationFrame(step); } else { relayout(); cb(); }
}
rafRef.current = requestAnimationFrame(step);
}
function loop() {
if (!isDemoRef.current) return;
setNarrow(true);
pollDuring(NARROW_MS, () => {
timerRef.current = window.setTimeout(() => {
if (!isDemoRef.current) return;
setNarrow(false);
pollDuring(WIDE_MS, () => { timerRef.current = window.setTimeout(loop, PAUSE_MS); });
}, PAUSE_MS);
});
}
if (!reduce) {
timerRef.current = window.setTimeout(loop, 300);
} else {
isDemoRef.current = false;
setIsDemo(false);
}
return () => { window.clearTimeout(timerRef.current); cancelAnimationFrame(rafRef.current); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function onClick() {
human();
setNarrow((n) => !n);
}
return (
<main className={styles.stage}>
<div className={styles.ov + " " + (isDemo ? styles.isDemo : "")}>
<div
className={styles.viewport + " " + (narrow ? styles.viewportNarrow : "")}
ref={viewportRef} onClick={onClick}
>
<div className={styles.row} ref={rowRef} role="list" aria-label="태그 목록, 좁아지면 접힘">
{WORDS.map((w, i) => (
<span key={w + i} data-chip className={styles.chip + " " + (i >= visibleCount ? styles.chipHidden : "")} role="listitem">{w}</span>
))}
<span className={styles.badge + " " + (hidden > 0 ? styles.badgeShow : "")} role="listitem">{"+" + hidden}</span>
</div>
</div>
<p className={styles.meta}>폭 <b>{width}</b>px · 접힌 태그 <b>{hidden}</b>개</p>
<p className={styles.sr} role="status">{live}</p>
</div>
</main>
);
}08 줄이 넘치면 「+N」으로 접는 칩 입력 — 측정 누적면적 2.418% · 움직인 프레임 6/23GODRICH 원작 · zip 에 SCSS·React 포함 · 태그 입력 ui 9가지 — 칩 UI 코드로 만들기
이 데모가 속한 글
태그 입력 ui 9가지 — 칩 UI 코드로 만들기태그 입력 UI 아홉 가지를 나란히 놓고 보면, 칩 목록 끝에 「+3」 표시가 뜨는 지점부터 본다 — 줄 너비 값이 칩 개수를 계산해 넘친 만큼 접고, 쉼표로 붙여넣은 문자열은 구분자 값으로 나눈 개수만큼 칩을 한 이 글의 데모 전부 보기


