OpenEmoji catalog: every emoji in the reference set, with search and filters (#197)

* OpenEmoji catalog: every emoji in the reference set, with search and filters

/openemoji/catalog reads the set's openemoji.json (every Unicode emoji,
drawn or not) and filters by name and CLDR keyword, group, subgroup,
skin tone (keyboard-style swap), Emoji version and status, with sort,
tile size, URL-synced filters, paged rendering and a detail panel
(codepoints, tone family, copy, PNG/SVG downloads, keyword chips).
Spec: an entry without files is listed, not drawn.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* OpenEmoji catalog: reference set previews, first 206 glyphs

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-09-23 21:27:40 -07:00 • committed by GitHub
parent 6d1bcd0e05
commit f80488ce92
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
419 changed files with 77688 additions and 2 deletions

View file

@ -0,0 +1,597 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ReactNode } from "react";
import styles from "./catalog.module.css";
import { REPO_RAW, SET_URL } from "./set";
/**
* The whole OpenEmoji set, filterable.
*
* Reads the set's own openemoji.json, which lists every emoji Unicode has,
* drawn or not (an entry without files is listed, not drawn), so this page is
* complete from the first glyph and fills in as the rest are drawn.
*
* Filters live in the query string, so a filtered view is a link.
*/
type Emoji = {
key: string;
char: string;
name: string;
group: string;
subgroup: string;
unicode: string;
keywords?: string[];
base?: string;
png?: string;
svg?: string;
webp?: string;
};
type Manifest = {
name: string;
version: string;
unicode: string;
license: string;
ai_model?: string;
sizes: number[];
webp_sizes?: number[];
coverage: { total: number; drawn: number };
emoji: Emoji[];
};
/** Key, swatch colour, label. Colour dots, not 🏻: a box with no emoji font is no swatch at all. */
const TONES: Array<[string, string, string]> = [
["1f3fb", "#f7d7c4", "light"],
["1f3fc", "#dfb896", "medium-light"],
["1f3fd", "#bb8e67", "medium"],
["1f3fe", "#8d5e3f", "medium-dark"],
["1f3ff", "#5b3a2c", "dark"]
];
const TONE_KEYS = new Set(TONES.map(([key]) => key));
const tonesOf = (e: Emoji): string[] => e.key.split("-").filter((cp) => TONE_KEYS.has(cp));
type Filters = {
q: string;
group: string;
sub: string;
/** "" = default (no tone), a tone key, or "all" for every variant. */
tone: string;
version: string;
status: "" | "drawn" | "todo";
sort: "unicode" | "name" | "newest";
size: number;
};
const DEFAULTS: Filters = { q: "", group: "", sub: "", tone: "", version: "", status: "", sort: "unicode", size: 56 };
const PAGE = 360;
function readFilters(): Filters {
const params = new URLSearchParams(window.location.search);
const size = Number(params.get("size"));
const status = params.get("status");
const sort = params.get("sort");
return {
q: params.get("q") ?? "",
group: params.get("group") ?? "",
sub: params.get("sub") ?? "",
tone: params.get("tone") ?? "",
version: params.get("v") ?? "",
status: status === "drawn" || status === "todo" ? status : "",
sort: sort === "name" || sort === "newest" ? sort : "unicode",
size: size >= 32 && size <= 128 ? size : DEFAULTS.size
};
}
function writeFilters(f: Filters, selected: string | null): void {
const params = new URLSearchParams();
if (f.q) params.set("q", f.q);
if (f.group) params.set("group", f.group);
if (f.sub) params.set("sub", f.sub);
if (f.tone) params.set("tone", f.tone);
if (f.version) params.set("v", f.version);
if (f.status) params.set("status", f.status);
if (f.sort !== "unicode") params.set("sort", f.sort);
if (f.size !== DEFAULTS.size) params.set("size", String(f.size));
if (selected) params.set("e", selected);
const query = params.toString();
window.history.replaceState(null, "", query ? `?${query}` : window.location.pathname);
}
const compareVersions = (a: string, b: string): number => {
const [a1 = 0, a2 = 0] = a.split(".").map(Number);
const [b1 = 0, b2 = 0] = b.split(".").map(Number);
return a1 - b1 || a2 - b2;
};
const codepoints = (key: string): string =>
key
.split("-")
.map((cp) => `U+${cp.toUpperCase()}`)
.join(" ");
/** Lowercased haystack per entry, built once. */
const haystackOf = (e: Emoji): string =>
[e.name, e.char, e.key, codepoints(e.key), e.group, e.subgroup, ...(e.keywords ?? [])].join(" ").toLowerCase();
const fill = (template: string, size: number): string => template.replace("{size}", String(size));
export function Catalog(): ReactNode {
const [manifest, setManifest] = useState<Manifest | null>(null);
const [error, setError] = useState<string | null>(null);
const [filters, setFilters] = useState<Filters>(DEFAULTS);
const [query, setQuery] = useState("");
const [shown, setShown] = useState(PAGE);
const [selected, setSelected] = useState<string | null>(null);
const [ready, setReady] = useState(false);
const sentinel = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const initial = readFilters();
setFilters(initial);
setQuery(initial.q);
setSelected(new URLSearchParams(window.location.search).get("e"));
setReady(true);
fetch(`${SET_URL}/openemoji.json`)
.then((response) => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<Manifest>;
})
.then(setManifest)
.catch((reason: Error) => setError(reason.message));
}, []);
// The search box types freely; the filter follows a beat behind.
useEffect(() => {
const timer = window.setTimeout(() => setFilters((f) => (f.q === query ? f : { ...f, q: query })), 140);
return () => window.clearTimeout(timer);
}, [query]);
useEffect(() => {
if (ready) writeFilters(filters, selected);
}, [filters, selected, ready]);
useEffect(() => setShown(PAGE), [filters]);
const index = useMemo(() => {
if (!manifest) return null;
const byKey = new Map(manifest.emoji.map((e) => [e.key, e]));
const variants = new Map<string, Emoji[]>();
for (const e of manifest.emoji) if (e.base) variants.set(e.base, [...(variants.get(e.base) ?? []), e]);
const hay = new Map(manifest.emoji.map((e) => [e.key, haystackOf(e)]));
const order = new Map(manifest.emoji.map((e, i) => [e.key, i]));
const groups = [...new Set(manifest.emoji.map((e) => e.group))];
const versions = [...new Set(manifest.emoji.map((e) => e.unicode))].sort(compareVersions).reverse();
return { byKey, variants, hay, order, groups, versions };
}, [manifest]);
const set = useCallback(<K extends keyof Filters>(key: K, value: Filters[K]) => {
setFilters((f) => {
const next = { ...f, [key]: value };
if (key === "group") next.sub = "";
return next;
});
}, []);
/** Everything but the tone view: what the group and status counts are taken over. */
const matchesBase = useCallback(
(e: Emoji, f: Filters, skip: Partial<Record<"group" | "status", boolean>> = {}): boolean => {
if (!index) return false;
if (!skip.group && f.group && e.group !== f.group) return false;
if (f.sub && e.subgroup !== f.sub) return false;
if (f.version && e.unicode !== f.version) return false;
if (!skip.status && f.status === "drawn" && !e.png) return false;
if (!skip.status && f.status === "todo" && e.png) return false;
if (f.q) {
const hay = index.hay.get(e.key) ?? "";
for (const word of f.q.toLowerCase().split(/\s+/).filter(Boolean)) if (!hay.includes(word)) return false;
}
return true;
},
[index]
);
/**
* The tone view, the way a keyboard does it: default shows the untoned
* glyphs; a tone shows each glyph in that tone where it has one (and as it
* is where it has none); "all" shows every variant.
*/
const toneView = useCallback(
(list: Emoji[], tone: string): Emoji[] => {
if (!index || tone === "all") return list;
if (!tone) return list.filter((e) => tonesOf(e).length === 0);
const out: Emoji[] = [];
for (const e of list) {
const tones = tonesOf(e);
if (tones.length > 0) continue;
const swap = (index.variants.get(e.key) ?? []).find((v) => {
const t = tonesOf(v);
return t.length > 0 && t.every((x) => x === tone);
});
out.push(swap ?? e);
}
return out;
},
[index]
);
const results = useMemo(() => {
if (!manifest || !index) return [];
// Tone swaps come from variants the other filters may have excluded
// (a search for "thumbs" still wants 👍🏽), so filter the toneless view
// first and swap after, then re-check the status filter on the swap.
const base = manifest.emoji.filter((e) => matchesBase(e, { ...filters, status: "" }));
let list = toneView(base, filters.tone);
if (filters.status === "drawn") list = list.filter((e) => e.png);
if (filters.status === "todo") list = list.filter((e) => !e.png);
if (filters.sort === "name") list = [...list].sort((a, b) => a.name.localeCompare(b.name));
if (filters.sort === "newest")
list = [...list].sort(
(a, b) => compareVersions(b.unicode, a.unicode) || (index.order.get(a.key) ?? 0) - (index.order.get(b.key) ?? 0)
);
return list;
}, [manifest, index, filters, matchesBase, toneView]);
const groupCounts = useMemo(() => {
const counts = new Map<string, number>();
if (!manifest) return counts;
const list = toneView(
manifest.emoji.filter((e) => matchesBase(e, filters, { group: true })),
filters.tone
);
for (const e of list) counts.set(e.group, (counts.get(e.group) ?? 0) + 1);
return counts;
}, [manifest, filters, matchesBase, toneView]);
const subgroups = useMemo(() => {
if (!manifest || !filters.group) return [];
return [...new Set(manifest.emoji.filter((e) => e.group === filters.group).map((e) => e.subgroup))];
}, [manifest, filters.group]);
// Render in pages as the reader scrolls: 4,000 tiles at once is a slow first paint.
useEffect(() => {
const node = sentinel.current;
if (!node) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) setShown((n) => n + PAGE);
},
{ rootMargin: "800px" }
);
observer.observe(node);
return () => observer.disconnect();
}, [results.length]);
const current = selected && index ? (index.byKey.get(selected) ?? null) : null;
// Arrow keys walk the results while the detail panel is open; Escape closes it.
useEffect(() => {
if (!current) return;
const onKey = (event: KeyboardEvent): void => {
if (event.key === "Escape") setSelected(null);
if (event.key !== "ArrowRight" && event.key !== "ArrowLeft") return;
const at = results.findIndex((e) => e.key === current.key);
if (at === -1) return;
const next = results[at + (event.key === "ArrowRight" ? 1 : -1)];
if (next) {
event.preventDefault();
setSelected(next.key);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [current, results]);
if (error) {
return <p className={styles.notice}>The set could not be loaded ({error}).</p>;
}
if (!manifest || !index) {
return <p className={styles.notice}>Loading the set…</p>;
}
const drawnPct = Math.round((manifest.coverage.drawn / manifest.coverage.total) * 100);
const active =
filters.q || filters.group || filters.sub || filters.tone || filters.version || filters.status || filters.sort !== "unicode";
return (
<div className={styles.catalog} style={{ ["--tile" as string]: `${filters.size}px` }}>
<div className={styles.progress} aria-label={`${manifest.coverage.drawn} of ${manifest.coverage.total} drawn`}>
<div className={styles.progressBar} style={{ width: `${drawnPct}%` }} />
<span>
{manifest.coverage.drawn.toLocaleString()} of {manifest.coverage.total.toLocaleString()} drawn · Unicode{" "}
{manifest.unicode} · {manifest.license} · set {manifest.version}
</span>
</div>
<div className={styles.toolbar}>
<input
className={styles.search}
type="search"
placeholder="Search 3,963 emoji: name, keyword, lol, U+1F600…"
value={query}
onChange={(event) => setQuery(event.target.value)}
aria-label="Search emoji"
/>
<div className={styles.controls}>
<label>
<span>Subgroup</span>
<select value={filters.sub} onChange={(e) => set("sub", e.target.value)} disabled={!filters.group}>
<option value="">{filters.group ? "All subgroups" : "Pick a group"}</option>
{subgroups.map((s) => (
<option key={s} value={s}>
{s.replace(/-/g, " ")}
</option>
))}
</select>
</label>
<label>
<span>Emoji version</span>
<select value={filters.version} onChange={(e) => set("version", e.target.value)}>
<option value="">Any</option>
{index.versions.map((v) => (
<option key={v} value={v}>
New in {v}
</option>
))}
</select>
</label>
<label>
<span>Status</span>
<select value={filters.status} onChange={(e) => set("status", e.target.value as Filters["status"])}>
<option value="">Drawn and not yet</option>
<option value="drawn">Drawn</option>
<option value="todo">Not drawn yet</option>
</select>
</label>
<label>
<span>Sort</span>
<select value={filters.sort} onChange={(e) => set("sort", e.target.value as Filters["sort"])}>
<option value="unicode">Unicode order</option>
<option value="name">Name A–Z</option>
<option value="newest">Newest first</option>
</select>
</label>
<label className={styles.sizeControl}>
<span>Size {filters.size}px</span>
<input
type="range"
min={32}
max={128}
step={8}
value={filters.size}
onChange={(e) => set("size", Number(e.target.value))}
/>
</label>
</div>
<div className={styles.row} role="group" aria-label="Group">
<button
type="button"
className={!filters.group ? styles.chipOn : styles.chip}
onClick={() => set("group", "")}
>
All groups
</button>
{index.groups.map((g) => (
<button
type="button"
key={g}
className={filters.group === g ? styles.chipOn : styles.chip}
onClick={() => set("group", filters.group === g ? "" : g)}
>
{g} <small>{(groupCounts.get(g) ?? 0).toLocaleString()}</small>
</button>
))}
</div>
<div className={styles.row} role="group" aria-label="Skin tone">
<span className={styles.rowLabel}>Skin tone</span>
<button type="button" className={!filters.tone ? styles.chipOn : styles.chip} onClick={() => set("tone", "")}>
Default
</button>
{TONES.map(([key, swatch, label]) => (
<button
type="button"
key={key}
title={`${label} skin tone`}
aria-label={`${label} skin tone`}
className={filters.tone === key ? styles.chipOn : styles.chip}
onClick={() => set("tone", filters.tone === key ? "" : key)}
>
<span className={styles.swatch} style={{ background: swatch }} />
</button>
))}
<button
type="button"
className={filters.tone === "all" ? styles.chipOn : styles.chip}
onClick={() => set("tone", filters.tone === "all" ? "" : "all")}
>
Every variant
</button>
<span className={styles.count}>
{results.length.toLocaleString()} {results.length === 1 ? "emoji" : "emoji"}
{active ? (
<button
type="button"
className={styles.reset}
onClick={() => {
setQuery("");
setFilters({ ...DEFAULTS, size: filters.size });
}}
>
Reset
</button>
) : null}
</span>
</div>
</div>
{results.length === 0 ? (
<p className={styles.notice}>Nothing matches. Try fewer words, or reset the filters.</p>
) : (
<ul className={styles.grid}>
{results.slice(0, shown).map((e) => (
<li key={e.key}>
<button
type="button"
className={`${styles.tile} ${e.png ? "" : styles.todo} ${selected === e.key ? styles.tileOn : ""}`}
onClick={() => setSelected(e.key)}
title={e.png ? e.name : `${e.name} (not drawn yet)`}
>
{e.webp ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`${SET_URL}/${fill(e.webp, 128)}`}
alt={e.char}
width={filters.size}
height={filters.size}
loading="lazy"
decoding="async"
/>
) : (
<span className={styles.fallback} aria-label={e.name}>
{e.char}
</span>
)}
{filters.size >= 48 ? <span className={styles.label}>{e.name}</span> : null}
</button>
</li>
))}
</ul>
)}
{shown < results.length ? <div ref={sentinel} className={styles.sentinel} /> : null}
{current ? (
<Detail
emoji={current}
variants={index.variants.get(current.base ?? current.key) ?? []}
base={current.base ? (index.byKey.get(current.base) ?? null) : null}
onSelect={setSelected}
onKeyword={(word) => {
setQuery(word);
setSelected(null);
}}
onClose={() => setSelected(null)}
/>
) : null}
</div>
);
}
function Detail({
emoji,
variants,
base,
onSelect,
onKeyword,
onClose
}: {
emoji: Emoji;
variants: Emoji[];
base: Emoji | null;
onSelect: (key: string) => void;
onKeyword: (word: string) => void;
onClose: () => void;
}): ReactNode {
const family = base ? [base, ...variants] : variants.length ? [emoji, ...variants] : [];
const imgTag = `<img class="openemoji" src="${REPO_RAW}/png/64/${emoji.key}.png" alt="${emoji.char}" title="${emoji.name}">`;
return (
<aside className={styles.detail} aria-label={`${emoji.name} details`}>
<button type="button" className={styles.close} onClick={onClose} aria-label="Close">
×
</button>
<div className={styles.hero}>
{emoji.png ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={`${SET_URL}/${fill(emoji.webp ?? emoji.png, 128)}`} alt={emoji.char} width={128} height={128} />
) : (
<span className={styles.heroFallback}>{emoji.char}</span>
)}
<span className={styles.native} title="How your system draws it">
{emoji.char}
</span>
</div>
<h3>{emoji.name}</h3>
{!emoji.png ? <p className={styles.todoNote}>Not drawn yet. The system emoji stands in until it is.</p> : null}
<dl className={styles.facts}>
<dt>Key</dt>
<dd>
<code>{emoji.key}</code>
</dd>
<dt>Codepoints</dt>
<dd>
<code>{codepoints(emoji.key)}</code>
</dd>
<dt>Group</dt>
<dd>
{emoji.group} › {emoji.subgroup.replace(/-/g, " ")}
</dd>
<dt>Emoji version</dt>
<dd>{emoji.unicode}</dd>
</dl>
{family.length > 1 ? (
<div className={styles.family}>
{family.map((v) => (
<button
type="button"
key={v.key}
className={v.key === emoji.key ? styles.familyOn : styles.familyItem}
onClick={() => onSelect(v.key)}
title={v.name}
>
{v.webp ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={`${SET_URL}/${fill(v.webp, 64)}`} alt={v.char} width={32} height={32} />
) : (
v.char
)}
</button>
))}
</div>
) : null}
<div className={styles.actions}>
<button type="button" className={styles.copy} data-copy={emoji.char}>
Copy emoji
</button>
<button type="button" className={styles.copy} data-copy={emoji.key}>
Copy key
</button>
{emoji.png ? (
<button type="button" className={styles.copy} data-copy={imgTag}>
Copy &lt;img&gt;
</button>
) : null}
</div>
{emoji.png ? (
<div className={styles.downloads}>
<span>Download</span>
{[512, 256, 128, 64].map((size) => (
<a key={size} href={`${REPO_RAW}/${fill(emoji.png!, size)}`} download>
PNG {size}
</a>
))}
{emoji.svg ? (
<a href={`${REPO_RAW}/${emoji.svg}`} download>
SVG
</a>
) : null}
</div>
) : null}
{emoji.keywords?.length ? (
<div className={styles.keywords}>
{emoji.keywords.map((word) => (
<button type="button" key={word} onClick={() => onKeyword(word)}>
{word}
</button>
))}
</div>
) : null}
</aside>
);
}