07디바운스 검색 — 마지막 입력 값 뒤 대기 시간이 지나야 실행되는 검색
입력 값이 바뀐 상태가 될 때마다 대기 시간을 재는 값을 처음부터 다시 시작하고, 그 값이 끝까지 줄어들 때까지 더 이상 입력이 없는 상태가 유지되면 그제서야 검색이 실행됩니다.
쓰임자동완성 검색창, 실시간 필터 목록
WAIT_MSrequestAnimationFrame--r

<main class="stage">
<div class="db is-demo" id="db">
<div class="db__field">
<input class="db__input" id="dbInput" type="text" value="" placeholder="검색어 입력" aria-label="검색">
<span class="db__bar" id="dbBar"></span>
</div>
<ul class="db__list" id="dbList"></ul>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 디바운스 검색 — 진행 막대 폭(--r)은 매 프레임 실제 경과 시간을 다시 재서 얻은 값이다.
// 입력이 다시 들어오면 경과 시간이 0으로 리셋되어 막대도 처음부터 다시 채워진다.
$duration: $dur-instant;
$easing: $ease-out;
$color: $subject-sky;
@include stage($stage-ink);
.db {
display: flex;
flex-direction: column;
gap: $sp-3;
width: min(100%, 260px);
}
.db__field {
position: relative;
border-radius: $r-control;
background: $stage-paper;
overflow: hidden;
}
.db__input {
box-sizing: border-box;
width: 100%;
border: 0;
background: transparent;
padding: $sp-3 $sp-4;
font-family: inherit;
font-weight: 700;
font-size: 13px;
color: $subject-ink;
outline: none;
}
.db__bar {
display: block;
height: 3px;
width: calc(var(--r, 0) * 100%);
background: $color;
transition: width $duration $easing;
}
.db__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $sp-1;
min-height: 96px;
}
.db__row {
padding: $sp-2 $sp-3;
border-radius: $r-sm;
background: rgba(255, 255, 255, .08);
font-weight: 600;
font-size: 12px;
color: $stage-paper;
}
@media (max-width: 340px) {
.db__row { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.db__bar { 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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.db {
display: flex;
flex-direction: column;
gap: 12px;
width: min(100%, 260px);
}
.db__field {
position: relative;
border-radius: 12px;
background: #ffffff;
overflow: hidden;
}
.db__input {
box-sizing: border-box;
width: 100%;
border: 0;
background: transparent;
padding: 12px 16px;
font-family: inherit;
font-weight: 700;
font-size: 13px;
color: #17141a;
outline: none;
}
.db__bar {
display: block;
height: 3px;
width: calc(var(--r, 0) * 100%);
background: #6ec8ff;
transition: width 140ms ease-out;
}
.db__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
min-height: 96px;
}
.db__row {
padding: 8px 12px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.08);
font-weight: 600;
font-size: 12px;
color: #ffffff;
}
@media (max-width: 340px) {
.db__row {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.db__bar {
transition: none !important;
}
}(function () {
var root = document.getElementById('db');
var input = document.getElementById('dbInput');
var bar = document.getElementById('dbBar');
var list = document.getElementById('dbList');
var WAIT_MS = 480;
var ITEMS = ['사과 파이 레시피', '사과나무 병충해', '사과주스 만들기', '바나나 스무디', '바나나빵 굽기'];
var timer = null;
var startedAt = 0;
var raf = null;
var autoplay = null;
// 계산 겹: 입력 값이 바뀔 때마다 남은 대기 시간을 처음부터 다시 잰다(디바운스).
// 진행 막대 폭은 실제 경과 시간 / WAIT_MS 비율로 매 프레임 다시 계산한다 — 고정 트랜지션이 아니다.
function tick() {
var elapsed = Date.now() - startedAt;
var ratio = Math.min(1, elapsed / WAIT_MS);
bar.style.setProperty('--r', ratio.toFixed(3));
if (ratio < 1) { raf = requestAnimationFrame(tick); }
}
function search(value) {
var q = value.trim().toLowerCase();
var matched = q ? ITEMS.filter(function (t) { return t.toLowerCase().indexOf(q) !== -1; }) : [];
list.innerHTML = '';
matched.forEach(function (text) {
var li = document.createElement('li');
li.className = 'db__row';
li.textContent = text;
list.appendChild(li);
});
bar.style.setProperty('--r', '0');
}
function onInput(value) {
input.value = value;
startedAt = Date.now();
bar.style.setProperty('--r', '0');
if (raf) { cancelAnimationFrame(raf); }
raf = requestAnimationFrame(tick);
clearTimeout(timer);
timer = setTimeout(function () { search(value); }, WAIT_MS);
}
input.addEventListener('input', function (e) {
root.classList.remove('is-demo');
stopAutoplay();
onInput(e.target.value);
});
function stopAutoplay() { if (autoplay) { clearInterval(autoplay); autoplay = null; } }
var script = ['사', '사과', '사과 ', '사과 파'];
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) {
var i = 0;
autoplay = setInterval(function () {
onInput(script[i % script.length]);
i += 1;
}, 640);
}
})();import { useEffect, useRef, useState } from "react";
import type { CSSProperties, ChangeEvent } from "react";
import styles from "./DebounceSearch.module.scss";
// 디바운스 검색 — 입력 값이 바뀔 때마다 남은 대기 시간을 처음부터 다시 잰다(디바운스).
// 계산 겹: 진행 막대 폭은 실제 경과 시간 / WAIT_MS 비율로 매 프레임 다시 계산한다 — 고정 트랜지션이 아니다.
const WAIT_MS = 480;
const ITEMS = ["사과 파이 레시피", "사과나무 병충해", "사과주스 만들기", "바나나 스무디", "바나나빵 굽기"];
const SCRIPT = ["사", "사과", "사과 ", "사과 파"];
export default function DebounceSearch() {
const rootRef = useRef<HTMLDivElement>(null);
const barRef = useRef<HTMLSpanElement>(null);
const [value, setValue] = useState("");
const [matched, setMatched] = useState<string[]>([]);
const timerRef = useRef<number | null>(null);
const rafRef = useRef<number | null>(null);
const startedAtRef = useRef(0);
const autoplayRef = useRef<number | null>(null);
function tick() {
const elapsed = Date.now() - (startedAtRef.current ?? 0);
const ratio = Math.min(1, elapsed / WAIT_MS);
barRef.current?.style.setProperty("--r", ratio.toFixed(3));
if (ratio < 1) { rafRef.current = requestAnimationFrame(tick); }
}
function search(v: string) {
const q = v.trim().toLowerCase();
const list = q ? ITEMS.filter((t) => t.toLowerCase().indexOf(q) !== -1) : [];
setMatched(list);
barRef.current?.style.setProperty("--r", "0");
}
function onInput(v: string) {
setValue(v);
startedAtRef.current = Date.now();
barRef.current?.style.setProperty("--r", "0");
if (rafRef.current) cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(tick);
if (timerRef.current) window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(() => search(v), WAIT_MS);
}
function stopAutoplay() {
if (autoplayRef.current) {
window.clearInterval(autoplayRef.current);
autoplayRef.current = null;
}
}
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) {
let i = 0;
autoplayRef.current = window.setInterval(() => {
onInput(SCRIPT[i % SCRIPT.length]);
i += 1;
}, 640);
}
return () => {
stopAutoplay();
if (timerRef.current) window.clearTimeout(timerRef.current);
if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div ref={rootRef} className={`${styles.db} ${styles["is-demo"]}`}>
<div className={styles.db__field}>
<input
className={styles.db__input}
type="text"
value={value}
placeholder="검색어 입력"
aria-label="검색"
onChange={(e: ChangeEvent<HTMLInputElement>) => {
rootRef.current?.classList.remove(styles["is-demo"]);
stopAutoplay();
onInput(e.target.value);
}}
/>
<span ref={barRef} className={styles.db__bar} style={{ "--r": 0 } as CSSProperties} />
</div>
<ul className={styles.db__list}>
{matched.map((text) => (
<li key={text} className={styles.db__row}>{text}</li>
))}
</ul>
</div>
</main>
);
}07 디바운스 검색 — 마지막 입력 값 뒤 대기 시간이 지나야 실행되는 검색 — 측정 누적면적 20.589% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 마이크로 인터랙션 사이트 UI 패턴 9종 — 토스트부터 스크롤 스파이까지
이 데모가 속한 글
마이크로 인터랙션 사이트 UI 패턴 9종 — 토스트부터 스크롤 스파이까지마이크로 인터랙션 사이트에 흔한 아홉 개 중 하나는 알림이 쌓인 순서 값으로 사라지는 시점을 다시 구해 뒤 알림 위치를 당기고, 하나는 스크롤된 위치에서 각 제목의 겹침 비율 값을 비교해 활성 항목을 바꾸며, 또 하 이 글의 데모 전부 보기

