Native Shell
Native Shell: AI-generated UI, native in your app, on every platform. Apps on iOS, Android, macOS, Windows and Linux, GPUI apps and web pages load signed, sandboxed TSX modules at run time and draw th
Add people to a list you already have: Goldpan spots who is already in, drops duplicates, fixes typos in one click and keeps to the places left.
Developer toolsTypeScript · JavaScriptGitHub @molinariCodeOnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
Add new email addresses to a list you already have and keep only the gold: people already in and duplicates spotted, typos fixed in one click, places left respected. Tiny, dependency-free, accessible multi-email input.
Taken from the repository README
Loading the full README.
Read the full README ↗New addresses for your list. Only the gold gets in.
A tiny, dependency-free multiple email input for adding people to a list you already have: team members,
subscribers, guests. Paste the new addresses from anywhere and it spots who is already in, drops duplicates, offers a
one-click fix for misspelled domains (gmial.com), stops at the places left and keeps your form in sync.
Most "email tag inputs" only say whether an address is well formed. Adding people to a list you already have is messier: a column copied from Excel, a line from Outlook with names in it, the same person twice, people who are already members, a provider spelled wrong, more people than places left.
Gold panning is the same job: you pour in gravel and keep the nuggets. Goldpan gives every address exactly one status:
| Status | Shown as | In getEmails() | Typical reasons |
|---|---|---|---|
valid | green, Valid | ✅ | everything checks out |
warning | amber, To check | ✅ (takes a place under maxEmails) | probable typo, already registered, your own rule's warning |
invalid | red, Rejected | ❌ | malformed, excluded or certainly misspelled domain, no place left |
Only red is left out. Amber addresses are accepted and flagged so a person can take a second look.
existingEmails)Name <address>
(Outlook, Gmail, Apple Mail), mailto: links, full-width separators (, ; 、)gmial.com, hotmial.com, gmail.con…) with a one-click fix,
tuned to leave real domains alone: regional ones like hotmail.be or yahoo.in, and companies such as aon.commaxEmails: when a place frees up, the first address waiting moves in*.domain wildcards, ready for large disposable-domain listsvalidate callbackrejected option)<input> always holds the accepted addresses, and a submit with rejected addresses
works the same way; honours required, disabled and form resetaria-pressed filters, polite live region, aria-invalid, no keyboard
traps, input methods (Chinese, Japanese, Android keyboards) handled, Windows high-contrast modenpm install goldpan
import Goldpan from 'goldpan';
import 'goldpan/style.css';
Or with <script> tags, no build step:
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/goldpan@1/dist/goldpan.min.css">
<script src="https://cdn.jsdelivr.net/npm/goldpan@1/dist/goldpan.global.min.js"></script>
<!-- optional: languages -->
<script src="https://cdn.jsdelivr.net/npm/goldpan@1/dist/locales/it.global.min.js"></script>
<label for="recipients">Recipients</label>
<input id="recipients" name="recipients" required>
const pan = new Goldpan('#recipients', {
maxEmails: 50,
existingEmails: ['[email protected]'],
});
pan.on('change', ({ emails, counts, hasErrors }) => {
console.log(emails); // accepted addresses (valid and to check), normalized
});
The original input is hidden and kept in sync ("[email protected], [email protected]"), so a plain form submit just works.
| Option | Type | Default | Description |
|---|---|---|---|
maxEmails | number | null | null | Most accepted addresses (valid and to check). null = no limit. |
onLimit | 'reject' | 'drop' | 'reject' | Beyond the limit: show in red, or do not add. |
duplicates | 'drop' | 'warn' | 'reject' | 'drop' | The same address twice. drop keeps the first one and highlights it; warn keeps both, flagged for a check, each taking a place; reject shows the copy in red. |
existingEmails | Iterable<string> | [] | Addresses already registered on your side. |
existing | 'warn' | 'reject' | 'warn' | What to do with existingEmails matches. |
blockedDomains | Iterable<string> | Goldpan.blockedDomains | Exclusion list, always red. Passing a list replaces the default (see below). |
typoCheck | 'warn' | 'reject' | false | 'warn' | Probable misspellings of popular providers. Both policies offer the fix button. |
knownDomains | Iterable<string> | [] | More correct domains for the typo check (also stops look-alikes from being reported). |
validate | (email) => null | { status, message } | – | Your own rule; return 'warning' or 'invalid' to flag an address. |
locale | string | object | 'en' | Registered language code or a messages object. |
Every option can be changed later with setOptions(); statuses are recomputed at once.
const pan = new Goldpan(target: string | HTMLElement, options?: GoldpanOptions);
pan.getEmails(): string[] // accepted (valid and to check)
pan.getEntries(status?): GoldpanEntry[] // { id, email, raw, status, reason, message, suggestion }
pan.getCounts(): { valid, warning, invalid, admitted, remaining }
pan.hasErrors(): boolean
pan.getState(): { emails, entries, counts, hasErrors }
pan.add(text | string[]): GoldpanEntry[] // parsed like a paste
pan.setValue(text | string[]): GoldpanEntry[] // replaces the whole list
pan.update(idOrEmail, text): GoldpanEntry | null
pan.remove(idOrEmail): boolean
pan.clear(): void
pan.confirm(): boolean // call it from your send button: true = go on
pan.setOptions(partial): void
pan.setFilter('all' | 'valid' | 'warning' | 'invalid'): void
pan.focus() / pan.enable() / pan.disable()
pan.destroy(): void // gives the original input back
pan.on(event, listener): () => void // returns an unsubscribe function
pan.off(event, listener): void
Goldpan.init(selector, options): Goldpan[]
Goldpan.getInstance(element | selector): Goldpan | undefined
Goldpan.registerLocale(code, messages): void
Goldpan.parse(text): string[]
Goldpan.isValidFormat(email): boolean
target can be an <input> or <textarea> (recommended, kept in sync) or any container element.
| Event | Payload |
|---|---|
change | { emails, entries, counts, hasErrors } |
add | { entries } |
remove | { entry } |
update | { before, after } |
duplicate | { dropped: string[] } |
limit | { rejected: string[] } |
filter | { filter } |
confirm | { state, message, label }: a confirmation stopped on rejected addresses, changed or ended |
The same events are dispatched as DOM CustomEvents on the original element, prefixed with goldpan:
(goldpan:change, goldpan:add…), so you can listen from anywhere:
document.addEventListener('goldpan:change', (e) => console.log(e.detail.emails));
Call confirm() from the button that uses the list: true means the accepted addresses can go. While rejected
addresses are present, the rejected option decides:
rejected | First press | Next press |
|---|---|---|
'ask' (default) | shows the rejected addresses and how many they are: false | goes on without them: true |
'block' | shows them, asking to fix or remove them: false | the same, until they are fixed |
'skip' | goes on without them: true |
The widget writes the message above the list. To show it next to your button instead, as the demo does, turn
inlineMessage off and follow the confirm event: it carries the message and, while the next press goes on, a label
for your button ('Ignore and send', in the language of the widget).
const pan = new Goldpan('#to', { inlineMessage: false });
pan.on('confirm', ({ state, message, label }) => {
note.textContent = message; // '' when nothing is pending
sendButton.textContent = state === 'ask' ? label : 'Send';
});
sendButton.onclick = () => {
if (pan.confirm()) send(pan.getEmails()); // accepted addresses only
};
A form submit counts as a confirmation too: with 'ask' the first submit stops and the next one goes through, with
'block' native validation stops it. The message follows the list (it counts what is left and goes away when nothing
is rejected any more), and a second press right after the first one, a double click, does not count.
const pan = new Goldpan('#invite', {
maxEmails: plan.seats - members.length, // places left
existingEmails: members, // shown as "Already registered"
});
inviteButton.onclick = async () => {
if (!pan.confirm()) return; // rejected addresses: shown first, the next press goes on without them
members.push(...(await sendInvites(pan.getEmails())));
pan.clear();
pan.setOptions({ maxEmails: plan.seats - members.length, existingEmails: members });
};
new Goldpan('#invite', {
existing: 'reject',
existingEmails: currentMembers,
validate: (email) =>
email.endsWith('@acme.com') ? null : { status: 'invalid', message: 'Only @acme.com addresses' },
});
Addresses on these domains are always red. The built-in list (DEFAULT_BLOCKED_DOMAINS in
src/domains.ts) holds misspellings so common they are certainly wrong (gmal.com,
hotmial.com…, still shown with the fix button) and well-known throw-away services (mailinator.com, yopmail.com…).
// Extend it for every instance (before creating them)
Goldpan.blockedDomains.push('competitor.com', '*.tempmail.dev');
// Extend it for one instance
new Goldpan('#to', { blockedDomains: [...Goldpan.blockedDomains, 'competitor.com'] });
// Replace it, or turn it off
new Goldpan('#to', { blockedDomains: ['competitor.com'] });
new Goldpan('#to', { blockedDomains: [] });
// Change it at runtime
pan.setOptions({ blockedDomains: [...Goldpan.blockedDomains, 'new-spam.com'] });
// A large external list (e.g. the 4,000+ domains of disposable-email-domains)
new Goldpan('#to', { blockedDomains: new Set([...Goldpan.blockedDomains, ...bigList]) });
*.domain.com covers the domain and all its sub-domains; *.xyz a whole top-level domain. For rules a list cannot
express, use validate. A maintained list of throw-away domains:
disposable-email-domains.
import Goldpan from 'goldpan';
import it from 'goldpan/locales/it';
Goldpan.registerLocale('it', it);
const pan = new Goldpan('#to', { locale: 'it' });
// or replace a few texts
new Goldpan('#to', { messages: { duplicate: 'Already invited' } });
Bundled: en (default), it, es, fr, de, pt, zh, ja. goldpan/locales/all exports every language.
With <script> tags, load dist/locales/<code>.global.min.js after the core.
The counter uses the plural rules of the language (1 address, 2 addresses): a message can be a string or an
object with one text per plural category ({ one: '…', other: '…' }).
New languages are very welcome: copy src/locales/en.ts, translate, add it to src/locales/all.ts.
Goldpan works on any input element. Create it after mount, destroy it on unmount:
function Recipients({ onChange }) {
const ref = useRef(null);
useEffect(() => {
const pan = new Goldpan(ref.current, { onChange: (s) => onChange(s.emails) });
return () => pan.destroy();
}, []);
return <input ref={ref} name="recipients" />;
}
To set the list from outside (a saved draft, a "reset" button), call pan.setValue(addresses).
All classes start with goldpan. Customise with CSS variables:
.goldpan {
--goldpan-font: 'Inter', sans-serif;
--goldpan-font-size: 1rem; /* every size of the widget follows it */
--goldpan-radius: 6px; /* corners of the address cards */
--goldpan-list-max-height: 240px;
--goldpan-focus: #7c3aed; /* focus ring, highlighted card */
--goldpan-fg: #1b1f24; --goldpan-muted: #5b6470;
--goldpan-bg: #ffffff; --goldpan-row: #f5f6f8; /* text box, address cards */
--goldpan-line: #dfe3e8; --goldpan-edge: #838d99; /* card border, text box border */
--goldpan-valid: #0f7a5a; --goldpan-valid-soft: #e6f4ee;
--goldpan-warning: #8a5a00; --goldpan-warning-soft: #fff4d6; --goldpan-warning-line: #e8c36b;
--goldpan-invalid: #b42318; --goldpan-invalid-soft: #fef0ee; --goldpan-invalid-line: #f1a69d;
}
theme: 'dark' switches every colour to a dark set, theme: 'auto' follows the operating system. The default
colours pass WCAG AA contrast in both.
See src/goldpan.css for the full list.
blockedDomains) → error; a misspelling on the list still gets the fix button.gmial.com, libreo.it), and in the ending with the same name
(gmail.con, hotmail.co). A real country ending is a regional service and is left alone (hotmail.be); the
first letter must match and very short names (aol, gmx) are compared only exactly, as they sit one letter
away from many real company domains. Add your own with knownDomains.validate callback.maxEmails is reached.Comparison ignores letter case; getEmails() returns lower-cased addresses. Goldpan checks the shape of an address,
not that the mailbox exists: always validate again on the server.
All evergreen browsers (Chrome, Edge, Firefox, Safari 14+). ES2020 output.
Node.js 22.18 or newer (the current LTS is recommended).
npm install
npm run dev # builds the demo, opens it on http://localhost:5173, reloads it on every change
npm test # unit tests (Vitest + jsdom)
npm run typecheck
npm run build # dist/: ESM, CommonJS, <script> builds, types, CSS
npm run build:site # site/: the demo as published (English, Italian in site/it/, sitemap), plus site/standalone.html (works offline)
npm run lint:package
demo/index.html is the source of the demo page: opened directly, it only explains how to start it.
Goldpan is free and open source (MIT). If it saves you time, give it a star on GitHub: it helps others find it.
MIT © 2026 Michele Molinari
messages |
object |
| – |
| Replace single messages. |
placeholder | string | original input, then locale | Placeholder of the text field. |
separator | string | ', ' | Separator written into the original input. |
rejected | 'ask' | 'block' | 'skip' | 'ask' | With rejected addresses, what confirm() and a form submit do: show them and go on at the next press, stop until they are fixed, or go on without them. See below. |
inlineMessage | boolean | true | Show the message about rejected addresses inside the widget; turn it off to show it next to your button. |
editable | 'all' | 'valid' | 'invalid' | 'none' | 'all' | Rows that show the edit button. |
showCounter | boolean | true | Show the counter above the list. |
theme | 'light' | 'dark' | 'auto' | 'light' | auto follows the operating system. |
className | string | – | Extra class on the root element. |
disabled | boolean | original input | Disable the widget. |
onChange | (state) => void | – | Shortcut for on('change'). |