04最近の検索チップ — 保存時刻を再計算し「3分前・2日前」ラベルを書き直すチップ
検索語とタイムスタンプをlocalStorageに積み、開くたびに現在時刻との差を分・時間・日の単位で再計算すると、正確な相対時刻ラベル付きの最近の検索チップになります。重複語は時刻だけ更新して先頭へ移動し、チップ削除時は残りのチップがスケールトゥイーンで詰まります。
使いどころコマース検索、コンテンツサイト
localStoragetransform: scaleCSS custom properties

<main class="stage">
<div class="rc" id="rc">
<p class="rc__hint">
<svg class="rc__ic" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24m0 192a88 88 0 1 1 88-88a88.1 88.1 0 0 1-88 88m64-88a8 8 0 0 1-8 8h-56a8 8 0 0 1-8-8V72a8 8 0 0 1 16 0v48h48a8 8 0 0 1 8 8"/></g></svg>
最近の検索は保存した時刻を毎回再計算して表示します
</p>
<ul class="rc__list" id="rcList" aria-label="最近の検索"></ul>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter;
$easing: $ease-out;
$color: $subject-blue-deep;
@include stage($stage-paper);
.rc {
width: 100%;
max-width: 400px;
display: flex;
flex-direction: column;
gap: $sp-3;
}
.rc__hint {
margin: 0;
padding: 0 $sp-1;
display: flex;
align-items: center;
gap: $sp-1;
font-weight: 400;
font-size: 11px;
line-height: 1.4;
color: rgba($subject-ink, .72);
}
.rc__ic {
width: 14px;
height: 14px;
flex: 0 0 auto;
color: $color;
}
.rc__list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.rc__chip {
--chip-i: 0;
display: flex;
align-items: center;
gap: $sp-1;
padding: $sp-1 $sp-1 $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-cream;
box-shadow: $shadow-press;
transition: transform $duration $easing, opacity $duration $easing, background-color $duration $easing;
}
.rc__chip--flash {
background: $color;
.rc__q,
.rc__time {
color: $subject-cream;
}
}
.rc__q {
font-weight: 500;
font-size: 12px;
color: $subject-ink;
}
.rc__time {
font-weight: 400;
font-size: 10px;
color: rgba($subject-ink, .72);
}
.rc__del {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: 0;
border-radius: 50%;
background: transparent;
color: $subject-ink;
cursor: pointer;
transition: background-color $duration $easing;
}
.rc__del:hover,
.rc__del:focus-visible {
background: rgba($subject-ink, .12);
}
.rc__delic {
width: 12px;
height: 12px;
}
@media (max-width: 340px) {
.rc { max-width: 260px; }
.rc__q { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.rc__chip {
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);
}
.rc {
width: 100%;
max-width: 400px;
display: flex;
flex-direction: column;
gap: 12px;
}
.rc__hint {
margin: 0;
padding: 0 4px;
display: flex;
align-items: center;
gap: 4px;
font-weight: 400;
font-size: 11px;
line-height: 1.4;
color: rgba(23, 20, 26, 0.72);
}
.rc__ic {
width: 14px;
height: 14px;
flex: 0 0 auto;
color: #0a4fe8;
}
.rc__list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.rc__chip {
--chip-i: 0;
display: flex;
align-items: center;
gap: 4px;
padding: 4px 4px 4px 12px;
border-radius: 999px;
background: #fff7e6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 600ms ease-out, opacity 600ms ease-out, background-color 600ms ease-out;
}
.rc__chip--flash {
background: #0a4fe8;
}
.rc__chip--flash .rc__q,
.rc__chip--flash .rc__time {
color: #fff7e6;
}
.rc__q {
font-weight: 500;
font-size: 12px;
color: #17141a;
}
.rc__time {
font-weight: 400;
font-size: 10px;
color: rgba(23, 20, 26, 0.72);
}
.rc__del {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: 0;
border-radius: 50%;
background: transparent;
color: #17141a;
cursor: pointer;
transition: background-color 600ms ease-out;
}
.rc__del:hover,
.rc__del:focus-visible {
background: rgba(23, 20, 26, 0.12);
}
.rc__delic {
width: 12px;
height: 12px;
}
@media (max-width: 340px) {
.rc {
max-width: 260px;
}
.rc__q {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.rc__chip {
transition: none;
}
}// 最近の検索 칩(04) — 검색어마다 {q, ts} 를 저장해 두고, 열 때마다 now-ts 를 분·시간·일
// 버킷으로 다시 계산해 상대 시각 라벨을 만든다. 중복 검색어는 ts 만 now 로 갱신해 맨
// 앞으로 옮기고(FLIP), 칩을 지우면 남은 칩들이 옛 위치→새 위치 델타만큼 스케일·이동
// 트윈으로 좁혀진다(getBoundingClientRect 두 번 재서 델타 계산).
(function () {
var list = document.getElementById('rcList');
if (!list) return;
var MIN = 60000, HOUR = 3600000, DAY = 86400000;
var STORE_KEY = 'rc-recent-demo';
var now = Date.now();
var seed = [
{ q: 'バナナ', ts: now - 45 * 1000 },
{ q: 'スイカ', ts: now - 40 * MIN },
{ q: 'パイナップル', ts: now - 12 * MIN },
{ q: 'グレープフルーツ', ts: now - 3 * HOUR },
{ q: 'チェリー', ts: now - 2 * DAY }
];
function loadItems() {
try {
var raw = window.localStorage.getItem(STORE_KEY);
if (!raw) return seed.slice();
var parsed = JSON.parse(raw);
if (Array.isArray(parsed) && parsed.length) return parsed;
} catch (e) { /* 저장소 접근 불가 시 시드로 대체 */ }
return seed.slice();
}
function saveItems() {
try { window.localStorage.setItem(STORE_KEY, JSON.stringify(items)); } catch (e) { /* 무시 */ }
}
var items = loadItems();
function label(delta) {
if (delta < MIN) return 'たった今';
if (delta < HOUR) return Math.floor(delta / MIN) + '分前';
if (delta < DAY) return Math.floor(delta / HOUR) + '時間前';
return Math.floor(delta / DAY) + '日前';
}
function render() {
var nowTick = Date.now();
var rows = items.map(function (it, i) {
return '<li class="rc__chip" data-i="' + i + '"><span class="rc__q">' + it.q +
'</span><span class="rc__time">' + label(nowTick - it.ts) + '</span>' +
'<button class="rc__del" type="button" data-i="' + i + '" aria-label="削除">' +
'<svg class="rc__delic" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 56v144a16 16 0 0 1-16 16H56a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2"/><path d="M205.66 194.34a8 8 0 0 1-11.32 11.32L128 139.31l-66.34 66.35a8 8 0 0 1-11.32-11.32L116.69 128L50.34 61.66a8 8 0 0 1 11.32-11.32L128 116.69l66.34-66.35a8 8 0 0 1 11.32 11.32L139.31 128Z"/></g></svg>' +
'</button></li>';
});
list.innerHTML = rows.join('');
}
render();
function measure() {
var m = {};
var chips = list.querySelectorAll('.rc__chip');
chips.forEach(function (el) { m[el.getAttribute('data-i')] = el.getBoundingClientRect(); });
return m;
}
function flip(before) {
var chips = list.querySelectorAll('.rc__chip');
chips.forEach(function (el) {
var i = el.getAttribute('data-i');
var b = before[i];
if (!b) { el.style.transform = 'scale(.6)'; el.style.opacity = '0'; }
else {
var a = el.getBoundingClientRect();
var dx = b.left - a.left, dy = b.top - a.top;
if (dx || dy) {
el.style.transition = 'none';
el.style.transform = 'translate(' + dx + 'px,' + dy + 'px)';
el.getBoundingClientRect();
}
}
requestAnimationFrame(function () {
el.style.transition = '';
el.style.transform = '';
el.style.opacity = '';
});
});
}
function bump(q) {
var before = measure();
var idx = items.findIndex(function (it) { return it.q === q; });
if (idx === -1) return;
items[idx].ts = Date.now();
var moved = items.splice(idx, 1)[0];
items.unshift(moved);
saveItems();
render();
flip(before);
var frontEl = list.querySelector('.rc__chip[data-i="0"]');
if (frontEl) {
frontEl.classList.add('rc__chip--flash');
setTimeout(function () { frontEl.classList.remove('rc__chip--flash'); }, 480);
}
}
function removeAt(q) {
var before = measure();
items = items.filter(function (it) { return it.q !== q; });
saveItems();
render();
flip(before);
}
var playing = true;
function stop() { playing = false; }
list.addEventListener('click', function (e) {
var btn = e.target.closest ? e.target.closest('.rc__del') : null;
if (!btn) return;
stop();
var i = Number(btn.getAttribute('data-i'));
var it = items[i];
if (it) removeAt(it.q);
});
list.addEventListener('pointerdown', function (e) {
if (!e.target.closest || !e.target.closest('.rc__del')) stop();
});
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce && playing) {
var STEPS = [
{ at: 300, fn: function () { bump('スイカ'); } },
{ at: 1000, fn: function () { removeAt('チェリー'); } }
];
STEPS.forEach(function (s) {
setTimeout(function () { if (playing) s.fn(); }, s.at);
});
setTimeout(function () {
if (!playing) return;
items = [
{ q: 'バナナ', ts: Date.now() - 45 * 1000 },
{ q: 'スイカ', ts: Date.now() - 40 * MIN },
{ q: 'パイナップル', ts: Date.now() - 12 * MIN },
{ q: 'グレープフルーツ', ts: Date.now() - 3 * HOUR },
{ q: 'チェリー', ts: Date.now() - 2 * DAY }
];
saveItems();
render();
}, 1750);
}
})();// SearchAutocompleteRecentChipRecency — vanilla 판(run/335/demo/04-search-autocomplete-recent-chip-recency/index.html)
// 의 React 포트. {q, ts} 를 저장해 두고 열 때마다 now-ts 를 분·시간·일 버킷으로 다시
// 계산해 상대 시각 라벨을 만든다. 중복 검색어는 ts 만 now 로 갱신해 맨 앞으로 옮기고
// (FLIP), 칩을 지우면 남은 칩들이 옛 위치→새 위치 델타만큼 트윈으로 좁혀진다.
import { useEffect, useRef } from "react";
import styles from "./SearchAutocompleteRecentChipRecency.module.scss";
export type SearchAutocompleteRecentChipRecencyProps = {
duration?: string;
easing?: string;
color?: string;
};
type Item = { q: string; ts: number };
const HINT = "최근 검색은 저장된 시각을 다시 계산해 보여줍니다";
const ARIA_LABEL = "최근 검색";
const DELETE_LABEL = "삭제";
const STORE_KEY = "rc-recent-demo-react";
const MIN = 60000;
const HOUR = 3600000;
const DAY = 86400000;
const ICON_CLOCK =
'<svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24m0 192a88 88 0 1 1 88-88a88.1 88.1 0 0 1-88 88m64-88a8 8 0 0 1-8 8h-56a8 8 0 0 1-8-8V72a8 8 0 0 1 16 0v48h48a8 8 0 0 1 8 8"/></g></svg>';
const ICON_X =
'<svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 56v144a16 16 0 0 1-16 16H56a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2"/><path d="M205.66 194.34a8 8 0 0 1-11.32 11.32L128 139.31l-66.34 66.35a8 8 0 0 1-11.32-11.32L116.69 128L50.34 61.66a8 8 0 0 1 11.32-11.32L128 116.69l66.34-66.35a8 8 0 0 1 11.32 11.32L139.31 128Z"/></g></svg>';
function label(delta: number): string {
if (delta < MIN) return "방금";
if (delta < HOUR) return Math.floor(delta / MIN) + "분 전";
if (delta < DAY) return Math.floor(delta / HOUR) + "시간 전";
return Math.floor(delta / DAY) + "일 전";
}
function seedItems(): Item[] {
const now = Date.now();
return [
{ q: "바나나", ts: now - 45 * 1000 },
{ q: "수박", ts: now - 40 * MIN },
{ q: "파인애플", ts: now - 12 * MIN },
{ q: "자몽", ts: now - 3 * HOUR },
{ q: "체리", ts: now - 2 * DAY }
];
}
function loadItems(): Item[] {
try {
const raw = window.localStorage.getItem(STORE_KEY);
if (!raw) return seedItems();
const parsed = JSON.parse(raw) as Item[];
if (Array.isArray(parsed) && parsed.length) return parsed;
} catch (e) {
/* 저장소 접근 불가 시 시드로 대체 */
}
return seedItems();
}
export default function SearchAutocompleteRecentChipRecency({
duration = "600ms",
easing = "ease-out",
color = "#0a4fe8"
}: SearchAutocompleteRecentChipRecencyProps) {
const listRef = useRef<HTMLUListElement | null>(null);
useEffect(() => {
const list = listRef.current;
if (!list) return;
let items = loadItems();
function saveItems(): void {
try {
window.localStorage.setItem(STORE_KEY, JSON.stringify(items));
} catch (e) {
/* 무시 */
}
}
function render(): void {
const nowTick = Date.now();
list!.innerHTML = items
.map(
(it, i) =>
'<li class="' + styles.chip + '" data-i="' + i + '"><span class="' + styles.q + '">' + it.q +
'</span><span class="' + styles.time + '">' + label(nowTick - it.ts) + '</span>' +
'<button class="' + styles.del + '" type="button" data-i="' + i + '" aria-label="' + DELETE_LABEL + '">' +
'<span class="' + styles.delic + '">' + ICON_X + "</span></button></li>"
)
.join("");
}
render();
function measure(): Record<string, DOMRect> {
const m: Record<string, DOMRect> = {};
list!.querySelectorAll<HTMLElement>("." + styles.chip).forEach((el) => {
const i = el.getAttribute("data-i");
if (i) m[i] = el.getBoundingClientRect();
});
return m;
}
function flip(before: Record<string, DOMRect>): void {
const chips = list!.querySelectorAll<HTMLElement>("." + styles.chip);
chips.forEach((el) => {
const i = el.getAttribute("data-i") || "";
const b = before[i];
if (!b) {
el.style.transform = "scale(.6)";
el.style.opacity = "0";
} else {
const a = el.getBoundingClientRect();
const dx = b.left - a.left;
const dy = b.top - a.top;
if (dx || dy) {
el.style.transition = "none";
el.style.transform = "translate(" + dx + "px," + dy + "px)";
el.getBoundingClientRect();
}
}
requestAnimationFrame(() => {
el.style.transition = "";
el.style.transform = "";
el.style.opacity = "";
});
});
}
function bump(q: string): void {
const before = measure();
const idx = items.findIndex((it) => it.q === q);
if (idx === -1) return;
items[idx].ts = Date.now();
const moved = items.splice(idx, 1)[0];
items.unshift(moved);
saveItems();
render();
flip(before);
const frontEl = list!.querySelector<HTMLElement>('.' + styles.chip + '[data-i="0"]');
if (frontEl) {
frontEl.classList.add(styles.chipFlash);
setTimeout(() => frontEl.classList.remove(styles.chipFlash), 480);
}
}
function removeAt(q: string): void {
const before = measure();
items = items.filter((it) => it.q !== q);
saveItems();
render();
flip(before);
}
let playing = true;
function stop(): void {
playing = false;
}
function onClick(e: MouseEvent): void {
const target = e.target as HTMLElement;
const btn = target.closest ? (target.closest("." + styles.del) as HTMLElement | null) : null;
if (!btn) return;
stop();
const i = Number(btn.getAttribute("data-i"));
const it = items[i];
if (it) removeAt(it.q);
}
function onPointerDown(e: PointerEvent): void {
const target = e.target as HTMLElement;
if (!target.closest || !target.closest("." + styles.del)) stop();
}
list.addEventListener("click", onClick);
list.addEventListener("pointerdown", onPointerDown);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const timers: number[] = [];
if (!reduce) {
timers.push(window.setTimeout(() => { if (playing) bump("수박"); }, 300));
timers.push(window.setTimeout(() => { if (playing) removeAt("체리"); }, 1000));
timers.push(
window.setTimeout(() => {
if (!playing) return;
items = seedItems();
saveItems();
render();
}, 1750)
);
}
return () => {
timers.forEach((t) => window.clearTimeout(t));
list.removeEventListener("click", onClick);
list.removeEventListener("pointerdown", onPointerDown);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div className={styles.rc}>
<p className={styles.hint}>
<span className={styles.ic} dangerouslySetInnerHTML={{ __html: ICON_CLOCK }} />
{HINT}
</p>
<ul
className={styles.list}
ref={listRef}
aria-label={ARIA_LABEL}
style={{ transitionDuration: duration, transitionTimingFunction: easing, caretColor: color }}
/>
</div>
</main>
);
}04 最近の検索チップ — 保存時刻を再計算し「3分前・2日前」ラベルを書き直すチップ — 計測 累積面積 7.149% · 動いたフレーム 7/23GODRICH オリジナル · SCSS と React は zip に同梱 · オートコンプリート 実装 — ハイライト・デバウンス・編集距離を重ねた9種
このデモが属する記事
オートコンプリート 実装 — ハイライト・デバウンス・編集距離を重ねた9種検索窓のオートコンプリート 実装は文字を打つたびに再計算が走る一枚の層です。入力した文字が候補のどこにあるかを見つけてその部分だけ太字にし、300msのデバウンスでリクエストをまとめ、キーボードでもリストを巡れます。最近の検索・誤字候補・人 この記事のデモをすべて見る


