03입력한 글자와 이름 사이 유사도 값으로 줄을 세우는 검색
입력창에 글자를 넣을 때마다 이모지 이름과 그 글자 사이의 유사도 값을 다시 매겨, 완전히 같지 않아도 값이 높은 이모지부터 위에서부터 보여준다.
쓰임오타를 허용해야 하는 검색 상자, 단축어 검색
EMOJI_SEARCH_DEBOUNCE_MSscorerank

<main class="stage">
<div class="fz is-demo" id="fz">
<div class="fz__field">
<input class="fz__input" id="fzInput" type="text" placeholder="이모지 이름 검색" aria-label="이모지 이름 검색" autocomplete="off">
</div>
<ul class="fz__list" id="fzList" role="listbox" aria-label="검색 결과"></ul>
<p class="fz__sr" role="status" aria-live="polite" id="fzLive"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html score() 가 입력 글자를 이름 글자열 안에서 순서대로 찾아가며 연속·위치 가중치로
// 유사도 값을 매기고, rank() 는 그 값 내림차순으로 다시 줄을 세운다(FLIP 로 재배치를 보여준다).
@include stage($stage-orange);
.fz { width: min(100%, 300px); display: flex; flex-direction: column; gap: $sp-2; }
.fz__field { background: rgba($subject-cream, .85); border-radius: $r-control; padding: $sp-1 $sp-2; }
.fz__input {
width: 100%; border: none; background: transparent; font-size: 13px; color: $subject-ink; outline: none;
}
.fz__input::placeholder { color: rgba($subject-ink, .55); }
.fz__list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: $sp-1; max-height: 140px; overflow: hidden; }
.fz__item {
display: flex; align-items: center; gap: $sp-2; background: rgba($subject-cream, .7); border-radius: $r-sm;
padding: $sp-1 $sp-2; transition: transform $dur-base $ease-spring;
}
.fz__item--enter { animation: fzPop $dur-quick $ease-pop; }
.fz__item--exit { animation: fzOut $dur-quick $ease-pop forwards; }
.fz__ch { font-size: 16px; line-height: 1; }
.fz__name { font-size: 11px; color: $subject-ink; flex: 1 1 auto; }
// 계산 겹의 결과 값(유사도 s)을 정해진 배율로 그대로 막대 폭으로 바꿔 보여준다 — 값이 바뀔 때마다
// 폭이 전환되어, 순서가 안 바뀌는 글자에도 유사도 값 변화가 눈에 보인다.
.fz__bar { width: 40px; height: 4px; border-radius: $r-xs; background: rgba($subject-ink, .12); overflow: hidden; flex: 0 0 auto; }
.fz__barFill { display: block; height: 100%; width: 0%; background: $subject-ink; transition: width $dur-base $ease-spring; }
.fz__sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); margin: -1px; padding: 0; border: 0; }
@keyframes fzPop { 0% { opacity: 0; transform: scale(.7); } 100% { opacity: 1; transform: scale(1); } }
@keyframes fzOut { 0% { opacity: 1; transform: scale(1); } 100% { opacity: 0; transform: scale(.7); } }
@media (max-width: 340px) {
.fz { width: 100%; }
.fz__list { max-height: 120px; }
.fz__bar { width: 28px; }
}
@media (prefers-reduced-motion: reduce) {
.fz__item { transition: none; }
.fz__item--enter, .fz__item--exit { animation: none; }
.fz__barFill { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.fz {
width: min(100%, 300px);
display: flex;
flex-direction: column;
gap: 8px;
}
.fz__field {
background: rgba(255, 247, 230, 0.85);
border-radius: 12px;
padding: 4px 8px;
}
.fz__input {
width: 100%;
border: none;
background: transparent;
font-size: 13px;
color: #17141a;
outline: none;
}
.fz__input::placeholder {
color: rgba(23, 20, 26, 0.55);
}
.fz__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
max-height: 140px;
overflow: hidden;
}
.fz__item {
display: flex;
align-items: center;
gap: 8px;
background: rgba(255, 247, 230, 0.7);
border-radius: 8px;
padding: 4px 8px;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.fz__item--enter {
animation: fzPop 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.fz__item--exit {
animation: fzOut 220ms cubic-bezier(0.2, 1.4, 0.4, 1) forwards;
}
.fz__ch {
font-size: 16px;
line-height: 1;
}
.fz__name {
font-size: 11px;
color: #17141a;
flex: 1 1 auto;
}
.fz__bar {
width: 40px;
height: 4px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.12);
overflow: hidden;
flex: 0 0 auto;
}
.fz__barFill {
display: block;
height: 100%;
width: 0%;
background: #17141a;
transition: width 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.fz__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
margin: -1px;
padding: 0;
border: 0;
}
@keyframes fzPop {
0% {
opacity: 0;
transform: scale(0.7);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes fzOut {
0% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(0.7);
}
}
@media (max-width: 340px) {
.fz {
width: 100%;
}
.fz__list {
max-height: 120px;
}
.fz__bar {
width: 28px;
}
}
@media (prefers-reduced-motion: reduce) {
.fz__item {
transition: none;
}
.fz__item--enter, .fz__item--exit {
animation: none;
}
.fz__barFill {
transition: none;
}
}(function () {
var EMOJI_SEARCH_DEBOUNCE_MS = 200;
var ITEMS = [
{ ch: '\u{1F600}', name: 'grinning face' }, { ch: '\u{1F602}', name: 'joy tears' },
{ ch: '\u{1F525}', name: 'fire flame' }, { ch: '\u{1F31F}', name: 'glowing star' },
{ ch: '\u{1F389}', name: 'party popper' }, { ch: '\u{1F9E0}', name: 'brain idea' },
{ ch: '\u{1F680}', name: 'rocket launch' }, { ch: '\u{1F338}', name: 'cherry blossom' },
{ ch: '\u{1F4A7}', name: 'water drop' }, { ch: '\u{1F308}', name: 'rainbow arc' },
{ ch: '\u{1F52C}', name: 'science lab' }, { ch: '\u{1F9ED}', name: 'compass guide' }
];
var root = document.getElementById('fz');
var input = document.getElementById('fzInput');
var listEl = document.getElementById('fzList');
var live = document.getElementById('fzLive');
var timer = 0;
var synthetic = false;
function human() { if (synthetic) return; if (root.classList.contains('is-demo')) root.classList.remove('is-demo'); }
// 계산 겹: 입력 글자를 이름 글자열 안에서 순서대로 찾아가며, 연속으로 이어질수록·앞쪽에서
// 맞을수록 유사도 값을 더 높게 쳐서 완전히 같지 않아도 값 순서로 줄을 세운다.
function score(query, name) {
if (!query) return 1;
var qi = 0, s = 0, streak = 0;
for (var ni = 0; ni < name.length && qi < query.length; ni++) {
if (name[ni] === query[qi]) {
streak++;
s += 1 + streak * 2 + (ni === 0 ? 4 : 0);
qi++;
} else {
streak = 0;
}
}
if (qi < query.length) return 0;
return s;
}
function rank(query) {
var q = query.trim().toLowerCase();
return ITEMS.map(function (it) { return { it: it, s: score(q, it.name) }; })
.filter(function (r) { return r.s > 0; })
.sort(function (a, b) { return b.s - a.s; });
}
var liByName = {};
var mounted = false;
// list 가 바뀔 때마다 통째로 다시 그리지 않는다 — 남는 항목은 위치만 옮기고(FLIP)
// 유사도 값으로 정한 막대 폭만 새로 매겨 전환시킨다. 그래야 값이 바뀔 때마다 화면이
// 계속 움직인다(키만 눌렀다 하면 순간 교체로 끝나던 것과 달리).
function renderList(query) {
var ranked = rank(query);
var keep = {};
var before = {};
Array.prototype.forEach.call(listEl.children, function (li) { before[li.dataset.name] = li.getBoundingClientRect(); });
ranked.forEach(function (r) {
var it = r.it, pct = Math.min(100, Math.max(8, Math.round(r.s * 3)));
keep[it.name] = true;
var li = liByName[it.name];
if (!li) {
li = document.createElement('li');
li.className = mounted ? 'fz__item fz__item--enter' : 'fz__item'; li.dataset.name = it.name;
li.setAttribute('role', 'option'); li.setAttribute('aria-selected', 'false');
li.innerHTML = '<span class="fz__ch">' + it.ch + '</span><span class="fz__name">' + it.name +
'</span><span class="fz__bar"><span class="fz__barFill"></span></span>';
liByName[it.name] = li;
listEl.appendChild(li);
if (mounted) {
requestAnimationFrame(function () {
li.classList.remove('fz__item--enter');
li.querySelector('.fz__barFill').style.width = pct + '%';
});
} else {
li.querySelector('.fz__barFill').style.width = pct + '%';
}
} else {
li.classList.remove('fz__item--exit');
listEl.appendChild(li);
li.querySelector('.fz__barFill').style.width = pct + '%';
}
});
Object.keys(liByName).forEach(function (name) {
if (keep[name]) return;
var li = liByName[name];
li.classList.add('fz__item--exit');
delete liByName[name];
window.setTimeout(function () { if (li.parentNode) li.parentNode.removeChild(li); }, 220);
});
Array.prototype.forEach.call(listEl.children, function (li) {
if (li.classList.contains('fz__item--enter') || li.classList.contains('fz__item--exit')) return;
var b = before[li.dataset.name];
if (!b) return;
var a = li.getBoundingClientRect(), dy = b.top - a.top;
if (Math.abs(dy) > .5) {
li.style.transition = 'none';
li.style.transform = 'translateY(' + dy + 'px)';
li.offsetHeight;
li.style.transition = '';
li.style.transform = '';
}
});
live.textContent = ranked.length + '개 일치';
}
function onInput() {
human();
var q = input.value;
window.clearTimeout(timer);
timer = window.setTimeout(function () { renderList(q); }, reduce ? 0 : EMOJI_SEARCH_DEBOUNCE_MS);
}
input.addEventListener('input', onInput);
renderList('');
mounted = true;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var WORDS = ['fire', 'roc', 'star', 'gui', ''];
var STEP = 260;
var wi = 0, ci = 0, phase = 'type', loopTimer = 0;
function typeLoop() {
if (!root.classList.contains('is-demo')) return;
var word = WORDS[wi % WORDS.length];
if (phase === 'type') {
ci++;
input.value = word.slice(0, ci);
synthetic = true; input.dispatchEvent(new Event('input')); synthetic = false;
if (ci >= word.length) { phase = 'erase'; loopTimer = window.setTimeout(typeLoop, STEP + 200); return; }
loopTimer = window.setTimeout(typeLoop, STEP);
return;
}
ci--;
input.value = word.slice(0, Math.max(ci, 0));
synthetic = true; input.dispatchEvent(new Event('input')); synthetic = false;
if (ci <= 0) { phase = 'type'; wi++; loopTimer = window.setTimeout(typeLoop, STEP); return; }
loopTimer = window.setTimeout(typeLoop, STEP);
}
if (!reduce) { loopTimer = window.setTimeout(typeLoop, 200); }
else { root.classList.remove('is-demo'); }
})();// EmojiSearchFuzzyMatchScore — vanilla 판(run/402/demo/03-.../index.html) 의 React 포트.
// score() 는 입력 글자를 이름 글자열 안에서 순서대로 찾아가며 연속·위치 가중치로 유사도 값을 매기고,
// rank() 는 그 값 내림차순으로 줄을 세운다. 목록은 React 가 다시 그리지 않고(liByName 로 노드를 재사용)
// FLIP 재배치 + 유사도 값을 그대로 막대 폭으로 바꾼 전환만 imperative 하게 준다 — vanilla 와 동일 동작.
import { useEffect, useRef, useState } from "react";
import styles from "./EmojiSearchFuzzyMatchScore.module.scss";
type Box<T> = { current: T };
const EMOJI_SEARCH_DEBOUNCE_MS = 200;
type Item = { ch: string; name: string };
const ITEMS: Item[] = [
{ ch: "\u{1F600}", name: "grinning face" }, { ch: "\u{1F602}", name: "joy tears" },
{ ch: "\u{1F525}", name: "fire flame" }, { ch: "\u{1F31F}", name: "glowing star" },
{ ch: "\u{1F389}", name: "party popper" }, { ch: "\u{1F9E0}", name: "brain idea" },
{ ch: "\u{1F680}", name: "rocket launch" }, { ch: "\u{1F338}", name: "cherry blossom" },
{ ch: "\u{1F4A7}", name: "water drop" }, { ch: "\u{1F308}", name: "rainbow arc" },
{ ch: "\u{1F52C}", name: "science lab" }, { ch: "\u{1F9ED}", name: "compass guide" },
];
const WORDS = ["fire", "roc", "star", "gui", ""];
const STEP = 260;
// 계산 겹: index.html score() 와 동일 — 입력 글자를 이름 글자열 안에서 순서대로 찾아가며
// 연속으로 이어질수록·앞쪽에서 맞을수록 유사도 값을 더 높게 쳐서 완전히 같지 않아도 값 순서로 줄을 세운다.
function score(query: string, name: string): number {
if (!query) return 1;
let qi = 0, s = 0, streak = 0;
for (let ni = 0; ni < name.length && qi < query.length; ni++) {
if (name[ni] === query[qi]) {
streak++;
s += 1 + streak * 2 + (ni === 0 ? 4 : 0);
qi++;
} else {
streak = 0;
}
}
if (qi < query.length) return 0;
return s;
}
function rank(query: string): { it: Item; s: number }[] {
const q = query.trim().toLowerCase();
return ITEMS.map((it) => ({ it, s: score(q, it.name) }))
.filter((r) => r.s > 0)
.sort((a, b) => b.s - a.s);
}
export default function EmojiSearchFuzzyMatchScore() {
const [isDemo, setIsDemo] = useState(true);
const isDemoRef = useRef(true) as unknown as Box<boolean>;
const synthRef = useRef(false) as unknown as Box<boolean>;
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const inputRef = useRef(null) as unknown as Box<HTMLInputElement>;
const listRef = useRef(null) as unknown as Box<HTMLUListElement>;
const liveRef = useRef(null) as unknown as Box<HTMLParagraphElement>;
const liByNameRef = useRef({} as { [k: string]: HTMLLIElement }) as unknown as Box<{ [k: string]: HTMLLIElement }>;
const mountedRef = useRef(false) as unknown as Box<boolean>;
const timerRef = useRef(0) as unknown as Box<number>;
const loopTimerRef = useRef(0) as unknown as Box<number>;
const wiRef = useRef(0) as unknown as Box<number>;
const ciRef = useRef(0) as unknown as Box<number>;
const phaseRef = useRef("type") as unknown as Box<string>;
function human() {
if (synthRef.current) return;
if (isDemoRef.current) { isDemoRef.current = false; setIsDemo(false); }
}
function renderList(query: string) {
const listEl = listRef.current;
if (!listEl) return;
const liByName = liByNameRef.current;
const ranked = rank(query);
const keep: { [k: string]: boolean } = {};
const before: { [k: string]: DOMRect } = {};
Array.prototype.forEach.call(listEl.children, (li: HTMLLIElement) => { before[li.dataset.name as string] = li.getBoundingClientRect(); });
ranked.forEach((r) => {
const it = r.it, pct = Math.min(100, Math.max(8, Math.round(r.s * 3)));
keep[it.name] = true;
let li = liByName[it.name];
if (!li) {
li = document.createElement("li");
li.className = mountedRef.current ? styles.item + " " + styles.itemEnter : styles.item;
li.dataset.name = it.name;
li.setAttribute("role", "option"); li.setAttribute("aria-selected", "false");
li.innerHTML = '<span class="' + styles.ch + '">' + it.ch + '</span><span class="' + styles.name + '">' + it.name +
'</span><span class="' + styles.bar + '"><span class="' + styles.barFill + '"></span></span>';
liByName[it.name] = li;
listEl.appendChild(li);
if (mountedRef.current) {
requestAnimationFrame(() => {
li!.classList.remove(styles.itemEnter);
const fill = li!.querySelector("." + styles.barFill) as HTMLElement | null;
if (fill) fill.style.width = pct + "%";
});
} else {
const fill = li.querySelector("." + styles.barFill) as HTMLElement | null;
if (fill) fill.style.width = pct + "%";
}
} else {
li.classList.remove(styles.itemExit);
listEl.appendChild(li);
const fill = li.querySelector("." + styles.barFill) as HTMLElement | null;
if (fill) fill.style.width = pct + "%";
}
});
Object.keys(liByName).forEach((name) => {
if (keep[name]) return;
const li = liByName[name];
li.classList.add(styles.itemExit);
delete liByName[name];
window.setTimeout(() => { if (li.parentNode) li.parentNode.removeChild(li); }, 220);
});
Array.prototype.forEach.call(listEl.children, (li: HTMLLIElement) => {
if (li.classList.contains(styles.itemEnter) || li.classList.contains(styles.itemExit)) return;
const b = before[li.dataset.name as string];
if (!b) return;
const a = li.getBoundingClientRect(), dy = b.top - a.top;
if (Math.abs(dy) > 0.5) {
li.style.transition = "none";
li.style.transform = "translateY(" + dy + "px)";
li.offsetHeight;
li.style.transition = "";
li.style.transform = "";
}
});
if (liveRef.current) liveRef.current.textContent = ranked.length + "개 일치";
}
function onInput() {
human();
const q = inputRef.current ? inputRef.current.value : "";
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(() => { renderList(q); }, reduce ? 0 : EMOJI_SEARCH_DEBOUNCE_MS);
}
useEffect(() => {
renderList("");
mountedRef.current = true;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function typeLoop() {
if (!isDemoRef.current) return;
const input = inputRef.current;
if (!input) return;
const word = WORDS[wiRef.current % WORDS.length];
if (phaseRef.current === "type") {
ciRef.current++;
input.value = word.slice(0, ciRef.current);
synthRef.current = true; input.dispatchEvent(new Event("input")); synthRef.current = false;
if (ciRef.current >= word.length) { phaseRef.current = "erase"; loopTimerRef.current = window.setTimeout(typeLoop, STEP + 200); return; }
loopTimerRef.current = window.setTimeout(typeLoop, STEP);
return;
}
ciRef.current--;
input.value = word.slice(0, Math.max(ciRef.current, 0));
synthRef.current = true; input.dispatchEvent(new Event("input")); synthRef.current = false;
if (ciRef.current <= 0) { phaseRef.current = "type"; wiRef.current++; loopTimerRef.current = window.setTimeout(typeLoop, STEP); return; }
loopTimerRef.current = window.setTimeout(typeLoop, STEP);
}
if (!reduce) { loopTimerRef.current = window.setTimeout(typeLoop, 200); }
else { isDemoRef.current = false; setIsDemo(false); }
return () => { window.clearTimeout(timerRef.current); window.clearTimeout(loopTimerRef.current); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div className={styles.fz + " " + (isDemo ? styles.isDemo : "")} ref={rootRef}>
<div className={styles.field}>
<input className={styles.input} type="text" placeholder="이모지 이름 검색" aria-label="이모지 이름 검색"
autoComplete="off" ref={inputRef} onInput={onInput} />
</div>
<ul className={styles.list} role="listbox" aria-label="검색 결과" ref={listRef} />
<p className={styles.sr} role="status" aria-live="polite" ref={liveRef} />
</div>
</main>
);
}03 입력한 글자와 이름 사이 유사도 값으로 줄을 세우는 검색 — 측정 누적면적 33.422% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 이모지 검색기 9가지 — 값 하나로 여닫는 패턴
이 데모가 속한 글
이모지 검색기 9가지 — 값 하나로 여닫는 패턴이모지 검색기 아홉 가지를 나란히 놓고 보면, 입력창 아래 남은 공간 값이 패널 높이보다 작을 때만 패널이 위로 열리는 자리로 바뀌고, 누르고 있는 시간 값이 정해둔 값을 넘을 때만 같은 이모지의 변형이 부채꼴로 펼 이 글의 데모 전부 보기


