03Search That Ranks by a Similarity Score Between Typed Text and Name
Each keystroke re-scores a similarity value between the typed text and every emoji name, and results list from the highest-scoring emoji down even when the match isn't exact.
Best forTypo-tolerant search boxes, shortcode search
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="Search emoji name" aria-label="Search emoji name" autocomplete="off">
</div>
<ul class="fz__list" id="fzList" role="listbox" aria-label="Search results"></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 + ' matches';
}
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 Search That Ranks by a Similarity Score Between Typed Text and Name — Measured area 33.422% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · Emoji Picker: 9 Patterns by One Value
The post this demo lives in
Emoji Picker: 9 Patterns by One ValueNine emoji picker patterns: The panel flips upward only when the space-below value is smaller than its height, variants See all demos in this post
If you like this effect - similar demos
Inputs01 Match highlight — locating the typed letters inside each suggestion and bolding that spanAutocomplete Input: 9 Search Dropdowns
Inputs09 Tag Input That Ranks Suggestions by Closeness to What You Typed9 Tag Input UI Patterns With CSS Chips
Inputs03 Windows spectrum — a color picker that splits a 2D position into saturation and lightness9 Color Picker Design Patterns (Copy-Paste)