05Flex gap fallback — spacing that falls back to margins when the gap property isn't applied
When the gap property isn't supported, each item receives its own margin instead, mimicking the same spacing.
Best forA tag-list layout, a button-group alignment
gap@supportsmargin-right

<main class="stage">
<div class="wrap is-demo" id="wrap">
<div class="head">
<input class="addInput" id="addInput" type="text" placeholder="Type a tag, press Enter" maxlength="8">
<button class="simToggle" id="simToggle" type="button" aria-pressed="false">Simulate fallback</button>
</div>
<ul class="chipRow" id="chipRow">
<li class="chip">margin</li>
<li class="chip">token</li>
<li class="chip">align</li>
</ul>
<span class="modeLabel" id="modeLabel">gap mode</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 칩 등장 애니메이션의 --i 기반 시차(stagger)에 있다 — index.html 이 칩마다 순서를
// CSS 변수로 넘긴다. 여기는 두 간격 표기법(margin 폴백 · gap 지원)과 등장 곡선만 맡는다.
@include stage($stage-paper);
.wrap {
width: 260px;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.head {
display: flex;
align-items: center;
gap: $sp-2;
width: 100%;
}
.addInput {
flex: 1 1 auto;
min-width: 0;
border: none;
border-radius: $r-control;
background: $subject-cream;
color: $subject-ink;
padding: $sp-1 $sp-2;
font-size: 11px;
&::placeholder { color: rgba($subject-ink, .45); }
&:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
}
.simToggle {
flex: 0 0 auto;
appearance: none;
border: none;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
padding: $sp-1 $sp-2;
font-size: 10px;
font-weight: 600;
cursor: pointer;
transition: transform $dur-instant $ease-out;
&:active { transform: scale(.96); }
&[aria-pressed="true"] { background: $subject-orange-deep; }
&:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
}
.chipRow {
display: flex;
flex-wrap: wrap;
width: 100%;
min-height: 34px;
margin: 0;
padding: 0;
list-style: none;
// 폴백: gap 을 모르는 환경 — 마지막 칩만 빼고 오른쪽 여백을 준다. 항상 선언, 아래 @supports 가 지원판에서 덮는다
> .chip:not(:last-child) { margin-right: $sp-2; }
}
@supports (gap: 1rem) {
.wrap:not(.force-fallback) .chipRow {
gap: $sp-2;
> .chip:not(:last-child) { margin-right: 0; }
}
}
.chip {
appearance: none;
border: none;
border-radius: $r-pill;
background: $subject-blue;
color: $subject-cream;
padding: $sp-2 $sp-4;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transform: scale(1);
transition: transform $dur-enter $ease-elastic;
transition-delay: calc(var(--i, 0) * 40ms);
&:hover { transform: scale(1.08); }
}
.chip.is-entering { transform: scale(0); transition-duration: $dur-base; }
.chip.is-leaving { transform: scale(0); transition-delay: 0ms; }
.modeLabel {
color: $subject-ink;
font-size: 10px;
font-weight: 700;
}
@media (max-width: 340px) {
.wrap { width: 220px; }
.chipRow { min-height: 30px; }
}
@media (prefers-reduced-motion: reduce) {
.chip { transition: none; }
.simToggle { 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);
}
.wrap {
width: 260px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.head {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.addInput {
flex: 1 1 auto;
min-width: 0;
border: none;
border-radius: 12px;
background: #fff7e6;
color: #17141a;
padding: 4px 8px;
font-size: 11px;
}
.addInput::placeholder {
color: rgba(23, 20, 26, 0.45);
}
.addInput:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.simToggle {
flex: 0 0 auto;
appearance: none;
border: none;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
padding: 4px 8px;
font-size: 10px;
font-weight: 600;
cursor: pointer;
transition: transform 140ms ease-out;
}
.simToggle:active {
transform: scale(0.96);
}
.simToggle[aria-pressed=true] {
background: #c93000;
}
.simToggle:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.chipRow {
display: flex;
flex-wrap: wrap;
width: 100%;
min-height: 34px;
margin: 0;
padding: 0;
list-style: none;
}
.chipRow > .chip:not(:last-child) {
margin-right: 8px;
}
@supports (gap: 1rem) {
.wrap:not(.force-fallback) .chipRow {
gap: 8px;
}
.wrap:not(.force-fallback) .chipRow > .chip:not(:last-child) {
margin-right: 0;
}
}
.chip {
appearance: none;
border: none;
border-radius: 999px;
background: #2f6df6;
color: #fff7e6;
padding: 8px 16px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transform: scale(1);
transition: transform 600ms cubic-bezier(0.34, 1.56, 0.64, 1);
transition-delay: calc(var(--i, 0) * 40ms);
}
.chip:hover {
transform: scale(1.08);
}
.chip.is-entering {
transform: scale(0);
transition-duration: 300ms;
}
.chip.is-leaving {
transform: scale(0);
transition-delay: 0ms;
}
.modeLabel {
color: #17141a;
font-size: 10px;
font-weight: 700;
}
@media (max-width: 340px) {
.wrap {
width: 220px;
}
.chipRow {
min-height: 30px;
}
}
@media (prefers-reduced-motion: reduce) {
.chip {
transition: none;
}
.simToggle {
transition: none;
}
}(function () {
var STEP_MS = 380; // 자동 추가/삭제 gap
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var wrap = document.getElementById('wrap');
var chipRow = document.getElementById('chipRow');
var addInput = document.getElementById('addInput');
var simToggle = document.getElementById('simToggle');
var modeLabel = document.getElementById('modeLabel');
var isDemo = true;
function human() { if (isDemo) { wrap.classList.remove('is-demo'); isDemo = false; } }
var seq = 0;
function addChip(text) {
var li = document.createElement('li');
li.className = 'chip is-entering';
li.textContent = text;
li.style.setProperty('--i', String(seq % 6));
seq += 1;
li.addEventListener('click', function () { human(); removeChip(li); });
chipRow.appendChild(li);
requestAnimationFrame(function () {
requestAnimationFrame(function () { li.classList.remove('is-entering'); });
});
}
function removeChip(li) {
if (!li || li.classList.contains('is-leaving')) return;
li.classList.add('is-leaving');
li.addEventListener('transitionend', function () { li.remove(); }, { once: true });
}
addInput.addEventListener('keydown', function (e) {
if (e.key !== 'Enter') return;
human();
var value = addInput.value.trim();
if (value) { addChip(value); addInput.value = ''; }
});
var simOn = false;
function setSim(on) {
simOn = on;
wrap.classList.toggle('force-fallback', on);
simToggle.setAttribute('aria-pressed', String(on));
modeLabel.textContent = on ? 'margin fallback' : 'gap mode';
}
simToggle.addEventListener('click', function () { human(); setSim(!simOn); });
var WORDS = ['align', 'gap', 'rule', 'token', 'flow'];
var wi = 0;
var last = 0;
var phase = 0;
function loop(ts) {
if (!last) last = ts;
if (isDemo && !reduce && ts - last >= STEP_MS) {
last = ts;
phase = (phase + 1) % 3;
if (phase === 0) {
addChip(WORDS[wi % WORDS.length]);
wi += 1;
} else if (phase === 1 && chipRow.children.length > 2) {
removeChip(chipRow.lastElementChild);
} else {
setSim(!simOn);
}
}
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// GapFlexOld — vanilla 판(run/375/demo/05-compat-gap-flex-old/index.html) 의 React 포트.
// 폴백은 항상 margin-right 로 간격을 주고, @supports(gap) 지원판에서 gap 이 그 역할을 넘겨받는다.
import { useEffect, useRef } from "react";
import styles from "./GapFlexOld.module.scss";
type Box<T> = { current: T };
const STEP_MS = 380; // 자동 추가/삭제 간격
export default function GapFlexOld() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const chipRowRef = useRef(null) as unknown as Box<HTMLUListElement>;
const addInputRef = useRef(null) as unknown as Box<HTMLInputElement>;
const simToggleRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const modeLabelRef = useRef(null) as unknown as Box<HTMLSpanElement>;
const rafRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const wrap = rootRef.current;
const chipRow = chipRowRef.current;
const addInput = addInputRef.current;
const simToggle = simToggleRef.current;
const modeLabel = modeLabelRef.current;
if (!wrap || !chipRow || !addInput || !simToggle || !modeLabel) return;
let isDemo = true;
function human() { if (isDemo) { wrap!.classList.remove(styles.isDemo); isDemo = false; } }
let seq = 0;
function addChip(text: string) {
const li = document.createElement("li");
li.className = styles.chip + " " + styles.isEntering;
li.textContent = text;
li.style.setProperty("--i", String(seq % 6));
seq += 1;
li.addEventListener("click", function () { human(); removeChip(li); });
chipRow!.appendChild(li);
requestAnimationFrame(function () {
requestAnimationFrame(function () { li.classList.remove(styles.isEntering); });
});
}
function removeChip(li: Element | null) {
if (!li || li.classList.contains(styles.isLeaving)) return;
li.classList.add(styles.isLeaving);
li.addEventListener("transitionend", function () { (li as HTMLElement).remove(); }, { once: true });
}
function onKeydown(e: KeyboardEvent) {
if (e.key !== "Enter") return;
human();
const value = addInput!.value.trim();
if (value) { addChip(value); addInput!.value = ""; }
}
addInput.addEventListener("keydown", onKeydown);
let simOn = false;
function setSim(on: boolean) {
simOn = on;
wrap!.classList.toggle(styles.forceFallback, on);
simToggle!.setAttribute("aria-pressed", String(on));
modeLabel!.textContent = on ? "margin 폴백판" : "gap 지원판";
}
function onSimClick() { human(); setSim(!simOn); }
simToggle.addEventListener("click", onSimClick);
const WORDS = ["정렬", "간격", "규칙", "토큰", "흐름"];
let wi = 0;
let last = 0;
let phase = 0;
function loop(ts: number) {
if (!last) last = ts;
if (isDemo && !reduce && ts - last >= STEP_MS) {
last = ts;
phase = (phase + 1) % 3;
if (phase === 0) {
addChip(WORDS[wi % WORDS.length]);
wi += 1;
} else if (phase === 1 && chipRow!.children.length > 2) {
removeChip(chipRow!.lastElementChild);
} else {
setSim(!simOn);
}
}
rafRef.current = requestAnimationFrame(loop);
}
rafRef.current = requestAnimationFrame(loop);
return () => {
addInput!.removeEventListener("keydown", onKeydown);
simToggle!.removeEventListener("click", onSimClick);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.wrap + " " + styles.isDemo} ref={rootRef}>
<div className={styles.head}>
<input className={styles.addInput} ref={addInputRef} type="text" placeholder="태그 입력 후 Enter" maxLength={8} />
<button className={styles.simToggle} ref={simToggleRef} type="button" aria-pressed="false">폴백 흉내내기</button>
</div>
<ul className={styles.chipRow} ref={chipRowRef}>
<li className={styles.chip}>여백</li>
<li className={styles.chip}>토큰</li>
<li className={styles.chip}>정렬</li>
</ul>
<span className={styles.modeLabel} ref={modeLabelRef}>gap 지원판</span>
</div>
</main>
);
}05 Flex gap fallback — spacing that falls back to margins when the gap property isn't applied — Measured area 2.822% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · CSS Browser Compatibility: 9 Practical Fallbacks
The post this demo lives in
CSS Browser Compatibility: 9 Practical FallbacksNine css browser compatibility fixes: one recalculates height as the address bar resizes, one locks input zoom on focus, See all demos in this post
If you like this effect - similar demos
Buttons03 A Spring Return That Overshoots Back When the Slide Falls ShortSlide to Unlock Button: 9 Slider Scenes
Inputs04 Quantity that springs back when it passes the stock limitCheckout Page Design — 9 Cart Patterns, No Library
Interactions03 Fallback selection — a card that leaves a manual path when the fast one fails9 Copy Button CSS UI Patterns (Copy to Clipboard)