Skip to main content

Select / Dropdown

Controlled dropdown from PopupList. Single and multi-select, icons, groups, virtualised lists.

Quick start
import { Select } from "mates-design";

Select({
  items: [{ id: "a", label: "Alpha" }],
  selected: [],
  label: "Pick one",
  onChange: (ids) => {},
});

Variants — outlined / filled / ghost

import { html, atom, renderApp } from 'mates';
import { Select } from 'mates-ui';
import { FlagIcon } from 'mates-icons';

const COUNTRIES = [
{ id: 'us', label: 'United States', icon: FlagIcon(), subLabel: 'North America' },
{ id: 'uk', label: 'United Kingdom', icon: FlagIcon(), subLabel: 'Europe' },
{ id: 'de', label: 'Germany', icon: FlagIcon(), subLabel: 'Europe' },
{ id: 'fr', label: 'France', icon: FlagIcon(), subLabel: 'Europe' },
];

const App = () => {
const outlined = atom([]);
const filled = atom([]);
return () => html`
<x-col gap="4" style="max-width:32rem;padding:1rem;">
${Select({ items: COUNTRIES, label: 'Outlined (default)', placeholder: 'Choose…', selected: outlined(), onChange: ids => outlined.set(ids) })}
${Select({ items: COUNTRIES, label: 'Filled', variant: 'filled', placeholder: 'Choose…', selected: filled(), onChange: ids => filled.set(ids) })}
${Select({ items: [], label: 'Empty list', placeholder: 'No options' })}
</x-col>
`;
};
renderApp(App, document.getElementById('app'));

Sizes — sm / md / lg

import { html, atom, renderApp } from 'mates';
import { Select } from 'mates-ui';
import { FlagIcon } from 'mates-icons';

const COUNTRIES = [
{ id: 'us', label: 'United States', icon: FlagIcon(), subLabel: 'North America' },
{ id: 'uk', label: 'United Kingdom', icon: FlagIcon(), subLabel: 'Europe' },
{ id: 'de', label: 'Germany', icon: FlagIcon(), subLabel: 'Europe' },
];

const App = () => {
const sel = atom([]);
return () => html`
<x-col gap="4" style="max-width:32rem;padding:1rem;">
<x-row gap="3" align="end">
${Select({ items: COUNTRIES, label: 'Small', size: 'sm', placeholder: 'Select…', selected: sel(), onChange: ids => sel.set(ids) })}
${Select({ items: COUNTRIES, label: 'Medium', size: 'md', placeholder: 'Select…', selected: sel(), onChange: ids => sel.set(ids) })}
${Select({ items: COUNTRIES, label: 'Large', size: 'lg', placeholder: 'Select…', selected: sel(), onChange: ids => sel.set(ids) })}
</x-row>
<x-row gap="3" align="end">
${Select({ items: COUNTRIES, variant: 'filled', size: 'sm', placeholder: 'Small filled', selected: sel(), onChange: ids => sel.set(ids) })}
${Select({ items: COUNTRIES, variant: 'filled', size: 'md', placeholder: 'Medium filled', selected: sel(), onChange: ids => sel.set(ids) })}
${Select({ items: COUNTRIES, variant: 'filled', size: 'lg', placeholder: 'Large filled', selected: sel(), onChange: ids => sel.set(ids) })}
</x-row>
</x-col>
`;
};
renderApp(App, document.getElementById('app'));

States — helper text / error / disabled

import { html, atom, renderApp } from 'mates';
import { Select } from 'mates-ui';
import { AdminPanelSettingsIcon, EditIcon, VisibilityIcon, CommentIcon } from 'mates-icons';

const ROLES = [
{ id: 'admin', label: 'Admin', icon: AdminPanelSettingsIcon(), subLabel: 'Full access' },
{ id: 'editor', label: 'Editor', icon: EditIcon(), subLabel: 'Can edit content' },
{ id: 'viewer', label: 'Viewer', icon: VisibilityIcon(), subLabel: 'Read only' },
{ id: 'commenter', label: 'Commenter', icon: CommentIcon(), subLabel: 'Can comment' },
];

const App = () => {
const helper = atom([]);
const error = atom([]);
return () => html`
<x-row gap="4" style="max-width:40rem;padding:1rem;" align="start">
${Select({ items: ROLES, label: 'With helper', placeholder: 'Select role', helper: 'Controls what the user can do', selected: helper(), onChange: ids => helper.set(ids) })}
${Select({ items: ROLES, label: 'With error', placeholder: 'Select role', error: 'This field is required', selected: error(), onChange: ids => error.set(ids) })}
${Select({ items: ROLES, label: 'Disabled', placeholder: 'Not available', disabled: true })}
</x-row>
`;
};
renderApp(App, document.getElementById('app'));

Loading skeleton

import { html, renderApp } from 'mates';
import { Select } from 'mates-ui';

const App = () => () => html`
<div style="max-width:16rem;padding:1rem;">
${Select({ items: [], label: 'Async data', placeholder: 'Loading…', loading: true, skeletonCount: 4 })}
</div>
`;
renderApp(App, document.getElementById('app'));

With icons and subLabel

import { html, atom, renderApp } from 'mates';
import { Select } from 'mates-ui';
import { FlagIcon } from 'mates-icons';

const COUNTRIES = [
{ id: 'us', label: 'United States', icon: FlagIcon(), subLabel: 'North America' },
{ id: 'uk', label: 'United Kingdom', icon: FlagIcon(), subLabel: 'Europe' },
{ id: 'de', label: 'Germany', icon: FlagIcon(), subLabel: 'Europe' },
{ id: 'fr', label: 'France', icon: FlagIcon(), subLabel: 'Europe' },
{ id: 'jp', label: 'Japan', icon: FlagIcon(), subLabel: 'Asia' },
{ id: 'au', label: 'Australia', icon: FlagIcon(), subLabel: 'Oceania' },
];

const App = () => {
const sel = atom([]);
return () => html`
<div style="max-width:18rem;padding:1rem;">
${Select({ items: COUNTRIES, label: 'Country', placeholder: 'Choose a country', helper: 'Items include an icon and a subLabel', selected: sel(), onChange: ids => sel.set(ids) })}
${sel().length ? html`<p style="margin-top:.5rem;font-size:.75rem;color:var(--md-color-text-muted);">Selected: <strong>${sel()[0]}</strong></p>` : ''}
</div>
`;
};
renderApp(App, document.getElementById('app'));

autoComplete — searchable select

import { html, atom, renderApp } from 'mates';
import { Select } from 'mates-ui';
import { FlagIcon } from 'mates-icons';

const COUNTRIES = [
{ id: 'us', label: 'United States', icon: FlagIcon(), subLabel: 'North America' },
{ id: 'uk', label: 'United Kingdom', icon: FlagIcon(), subLabel: 'Europe' },
{ id: 'de', label: 'Germany', icon: FlagIcon(), subLabel: 'Europe' },
{ id: 'fr', label: 'France', icon: FlagIcon(), subLabel: 'Europe' },
{ id: 'jp', label: 'Japan', icon: FlagIcon(), subLabel: 'Asia' },
];
const TECH_TAGS = [
{ id: 'ts', label: 'TypeScript' }, { id: 'js', label: 'JavaScript' },
{ id: 'react', label: 'React' }, { id: 'vue', label: 'Vue' },
{ id: 'svelte', label: 'Svelte' }, { id: 'css', label: 'CSS' },
{ id: 'html', label: 'HTML' }, { id: 'node',label: 'Node.js' },
];

const App = () => {
const acSingle = atom([]);
const acMulti = atom([]);
return () => html`
<x-row gap="4" style="max-width:38rem;padding:1rem;" align="start">
<div>
${Select({ items: COUNTRIES, selected: acSingle(), label: 'Country (single)', placeholder: 'Search countries…', autoComplete: true, searchPlaceholder: 'Type to filter…', onChange: ids => acSingle.set(ids) })}
${acSingle().length ? html`<p style="margin-top:.5rem;font-size:.75rem;color:var(--md-color-text-muted);">Selected: <strong>${acSingle()[0]}</strong></p>` : ''}
</div>
<div>
${Select({ items: TECH_TAGS, selected: acMulti(), label: 'Tech stack (multi)', placeholder: 'Search tags…', autoComplete: true, multiple: true, searchPlaceholder: 'Filter tags…', onChange: ids => acMulti.set(ids) })}
${acMulti().length ? html`<p style="margin-top:.5rem;font-size:.75rem;color:var(--md-color-text-muted);">${acMulti().length} selected</p>` : ''}
</div>
</x-row>
`;
};
renderApp(App, document.getElementById('app'));

Controlled — single and filled variant

import { html, atom, renderApp } from 'mates';
import { Select } from 'mates-ui';
import { FlagIcon, AdminPanelSettingsIcon, EditIcon, VisibilityIcon } from 'mates-icons';

const COUNTRIES = [
{ id: 'us', label: 'United States', icon: FlagIcon(), subLabel: 'North America' },
{ id: 'uk', label: 'United Kingdom', icon: FlagIcon(), subLabel: 'Europe' },
{ id: 'de', label: 'Germany', icon: FlagIcon(), subLabel: 'Europe' },
];
const ROLES = [
{ id: 'admin', label: 'Admin', icon: AdminPanelSettingsIcon(), subLabel: 'Full access' },
{ id: 'editor', label: 'Editor', icon: EditIcon(), subLabel: 'Can edit' },
{ id: 'viewer', label: 'Viewer', icon: VisibilityIcon(), subLabel: 'Read only' },
];

const App = () => {
const country = atom([]);
const role = atom([]);
return () => html`
<x-row gap="4" style="max-width:36rem;padding:1rem;" align="start">
<div>
${Select({ items: COUNTRIES, selected: country(), label: 'Country', placeholder: 'Choose a country', onChange: ids => country.set(ids) })}
${country().length ? html`<p style="margin-top:.5rem;font-size:.75rem;color:var(--md-color-text-muted);">Selected: <strong>${country()[0]}</strong></p>` : ''}
</div>
<div>
${Select({ items: ROLES, selected: role(), label: 'Role', variant: 'filled', placeholder: 'Assign a role', onChange: ids => role.set(ids) })}
${role().length ? html`<p style="margin-top:.5rem;font-size:.75rem;color:var(--md-color-text-muted);">Selected: <strong>${role()[0]}</strong></p>` : ''}
</div>
</x-row>
`;
};
renderApp(App, document.getElementById('app'));

Multi-select

import { html, atom, renderApp } from 'mates';
import { Select, Badge } from 'mates-ui';
import { VisibilityIcon, EditIcon, DeleteIcon, DownloadIcon, ManageAccountsIcon } from 'mates-icons';

const TECH_TAGS = [
{ id: 'ts', label: 'TypeScript' }, { id: 'js', label: 'JavaScript' },
{ id: 'react', label: 'React' }, { id: 'vue', label: 'Vue' },
{ id: 'css', label: 'CSS' }, { id: 'node',label: 'Node.js' },
];
const PERMISSIONS = [
{ id: 'read', label: 'Read', icon: VisibilityIcon(), subLabel: 'View all content' },
{ id: 'write', label: 'Write', icon: EditIcon(), subLabel: 'Create & modify' },
{ id: 'delete', label: 'Delete', icon: DeleteIcon(), subLabel: 'Remove records' },
{ id: 'export', label: 'Export', icon: DownloadIcon(), subLabel: 'Download data' },
{ id: 'manage', label: 'Manage', icon: ManageAccountsIcon(), subLabel: 'Administer users' },
];

const App = () => {
const tags = atom([]);
const permissions = atom(['read', 'write']);
return () => html`
<x-col gap="4" style="max-width:22rem;padding:1rem;">
<div>
${Select({ items: TECH_TAGS, selected: tags(), label: 'Tech stack', multiple: true, placeholder: 'Pick technologies…', helper: 'Select all that apply', onChange: ids => tags.set(ids) })}
${tags().length ? html`<div style="margin-top:.5rem;display:flex;flex-wrap:wrap;gap:.25rem;">${tags().map(id => Badge(TECH_TAGS.find(t => t.id === id)?.label ?? id))}</div>` : ''}
</div>
${Select({ items: PERMISSIONS, selected: permissions(), label: 'Permissions', multiple: true, placeholder: 'No permissions selected', helper: 'Grant access levels', onChange: ids => permissions.set(ids) })}
</x-col>
`;
};
renderApp(App, document.getElementById('app'));

PopupList — check positions

import { html, atom, renderApp } from 'mates';
import { PopupList, CascadingPopupList } from 'mates-ui';
import { VisibilityIcon, EditIcon, DeleteIcon, DownloadIcon, ManageAccountsIcon, AdminPanelSettingsIcon, CommentIcon, NoteAddIcon, FolderOpenIcon, SaveIcon, CloseIcon } from 'mates-icons';

const PERMISSIONS = [
{ id: 'read', label: 'Read', icon: VisibilityIcon(), subLabel: 'View content' },
{ id: 'write', label: 'Write', icon: EditIcon(), subLabel: 'Create & modify' },
{ id: 'delete', label: 'Delete', icon: DeleteIcon(), subLabel: 'Remove records' },
{ id: 'manage', label: 'Manage', icon: ManageAccountsIcon(), subLabel: 'Administer users' },
];
const ROLES = [
{ id: 'admin', label: 'Admin', icon: AdminPanelSettingsIcon(), subLabel: 'Full access' },
{ id: 'editor', label: 'Editor', icon: EditIcon(), subLabel: 'Can edit' },
{ id: 'viewer', label: 'Viewer', icon: VisibilityIcon(), subLabel: 'Read only' },
{ id: 'commenter', label: 'Commenter', icon: CommentIcon(), subLabel: 'Can comment' },
];
const FILE_ACTIONS = [
{ id: 'new', label: 'New File', icon: NoteAddIcon() },
{ id: 'open', label: 'Open Recent', icon: FolderOpenIcon(), children: [
{ id: 'r1', label: 'project-alpha' }, { id: 'r2', label: 'project-beta' },
]},
{ id: 'save', label: 'Save', icon: SaveIcon() },
{ type: 'divider', label: '' },
{ id: 'close', label: 'Close', icon: CloseIcon() },
];

const App = () => {
const permissions = atom(['read']);
const role = atom([]);
const last = atom('—');
const box = 'border:1px solid var(--md-color-border);border-radius:var(--md-radius-lg);overflow:hidden;background:var(--md-color-surface-raised);box-shadow:var(--md-shadow-1);';
return () => html`
<x-col gap="3" style="padding:1rem;">
<x-row gap="4" align="start">
<div>
<p style="font-size:.75rem;color:var(--md-color-text-muted);margin-bottom:.5rem;">check · start</p>

CascadingPopupList — nested sub-panels

import { html, atom, renderApp } from 'mates';
import { CascadingPopupList } from 'mates-ui';
import { NoteAddIcon, FolderOpenIcon, SaveIcon, DownloadIcon, ImageIcon, CloseIcon } from 'mates-icons';

const FILE_ACTIONS = [
{ id: 'new', label: 'New File', icon: NoteAddIcon() },
{ id: 'open-recent', label: 'Open Recent', icon: FolderOpenIcon(), children: [
{ id: 'r1', label: 'project-alpha' },
{ id: 'r2', label: 'project-beta' },
{ id: 'r3', label: 'project-gamma' },
]},
{ id: 'save', label: 'Save', icon: SaveIcon() },
{ id: 'export', label: 'Export As…', icon: DownloadIcon(), children: [
{ id: 'ex-pdf', label: 'PDF Document' },
{ id: 'ex-png', label: 'PNG Image', icon: ImageIcon() },
{ id: 'ex-svg', label: 'SVG Vector' },
]},
{ type: 'divider', label: '' },
{ id: 'close', label: 'Close', icon: CloseIcon() },
];

const App = () => {
const last = atom('—');
const box = 'border:1px solid var(--md-color-border);border-radius:var(--md-radius-lg);overflow:hidden;background:var(--md-color-surface-raised);box-shadow:var(--md-shadow-1);display:inline-block;min-width:12rem;';
return () => html`
<div style="padding:1rem;">
<div style=${box}>${CascadingPopupList({ items: FILE_ACTIONS, autoFocus: false, onSelect: (_ids, item) => last.set(item?.label ?? '—') })}</div>
<p style="margin-top:.75rem;font-size:.875rem;color:var(--md-color-text-muted);">Last selected: <strong>${last()}</strong></p>
</div>
`;
};
renderApp(App, document.getElementById('app'));

Select trigger + CascadingPopupList panel

import { html, atom, popup, eleHook, renderApp } from 'mates';
import { CascadingPopupList } from 'mates-ui';
import { NoteAddIcon, FolderOpenIcon, SaveIcon, DownloadIcon, ImageIcon, CloseIcon, KeyboardArrowDownIcon } from 'mates-icons';

const FILE_ACTIONS = [
{ id: 'new', label: 'New File', icon: NoteAddIcon() },
{ id: 'open', label: 'Open Recent', icon: FolderOpenIcon(), children: [
{ id: 'r1', label: 'project-alpha' }, { id: 'r2', label: 'project-beta' },
]},
{ id: 'save', label: 'Save', icon: SaveIcon() },
{ id: 'export', label: 'Export As…', icon: DownloadIcon(), children: [
{ id: 'ex-pdf', label: 'PDF Document' }, { id: 'ex-png', label: 'PNG Image', icon: ImageIcon() },
]},
{ type: 'divider', label: '' },
{ id: 'close', label: 'Close', icon: CloseIcon() },
];

const App = () => {
const selected = atom('');
const triggerRef = { el: null };
const captureRef = eleHook($ => {
triggerRef.el = $.el;
return { onCleanup() { triggerRef.el = null; } };
});
const panel = CascadingPopupList({
items: FILE_ACTIONS, autoFocus: true,
onSelect: (_ids, item) => {
selected.set(item?.label ?? '');
requestAnimationFrame(() => document.dispatchEvent(new MouseEvent('click', { bubbles: true })));
},
onEscape: () => requestAnimationFrame(() => document.dispatchEvent(new MouseEvent('click', { bubbles: true }))),
});
const pop = popup(panel, {
position: 'bottom-start', gap: 4,
onOpen(_p) { triggerRef.el?.setAttribute('aria-expanded', 'true'); },
onClose(a) { a.setAttribute('aria-expanded', 'false'); a.focus(); },

Multi-select CascadingPopupList panel

import { html, atom, popup, eleHook, renderApp } from 'mates';
import { CascadingPopupList } from 'mates-ui';
import { CodeIcon, VisibilityIcon, EditIcon, DeleteIcon, ManageAccountsIcon, PersonIcon, SettingsIcon, AdminPanelSettingsIcon, KeyboardArrowDownIcon } from 'mates-icons';

const PERMISSION_TREE = [
{ id: 'content', label: 'Content', icon: CodeIcon(), children: [
{ id: 'content-read', label: 'Read', icon: VisibilityIcon() },
{ id: 'content-write', label: 'Write', icon: EditIcon() },
{ id: 'content-delete', label: 'Delete', icon: DeleteIcon() },
]},
{ id: 'users', label: 'Users', icon: ManageAccountsIcon(), children: [
{ id: 'users-view', label: 'View', icon: PersonIcon() },
{ id: 'users-invite', label: 'Invite', icon: PersonIcon() },
]},
{ id: 'settings', label: 'Settings', icon: SettingsIcon(), children: [
{ id: 'settings-read', label: 'Read', icon: VisibilityIcon() },
{ id: 'settings-write', label: 'Write', icon: EditIcon() },
]},
{ type: 'divider', label: '' },
{ id: 'admin', label: 'Admin (all)', icon: AdminPanelSettingsIcon() },
];

const App = () => {
const selected = atom([]);
const triggerRef = { el: null };
const captureRef = eleHook($ => {
triggerRef.el = $.el;
return { onCleanup() { triggerRef.el = null; } };
});
const panel = CascadingPopupList({
items: PERMISSION_TREE, checkType: 'check', selected: selected(), autoFocus: true,
onSelect: ids => selected.set(ids),
onEscape: () => requestAnimationFrame(() => document.dispatchEvent(new MouseEvent('click', { bubbles: true }))),
});
const pop = popup(panel, {
position: 'bottom-start', gap: 4,

PopupList — checkType radio / check / none

import { html, atom, renderApp } from 'mates';
import { PopupList } from 'mates-ui';
import { AdminPanelSettingsIcon, EditIcon, VisibilityIcon, CommentIcon } from 'mates-icons';

const ROLES = [
{ id: 'admin', label: 'Admin', icon: AdminPanelSettingsIcon(), subLabel: 'Full access' },
{ id: 'editor', label: 'Editor', icon: EditIcon(), subLabel: 'Can edit' },
{ id: 'viewer', label: 'Viewer', icon: VisibilityIcon(), subLabel: 'Read only' },
{ id: 'commenter', label: 'Commenter', icon: CommentIcon(), subLabel: 'Can comment' },
];
const TAGS = [
{ id: 'ts', label: 'TypeScript' }, { id: 'js', label: 'JavaScript' },
{ id: 'react', label: 'React' }, { id: 'vue', label: 'Vue' },
{ id: 'css', label: 'CSS' },
];

const App = () => {
const noneS = atom(['editor']);
const radioS = atom(['ts']);
const checkS = atom(['ts', 'react', 'css']);
const box = 'border:1px solid var(--md-color-border);border-radius:var(--md-radius-lg);overflow:hidden;background:var(--md-color-surface-raised);box-shadow:var(--md-shadow-1);min-width:11rem;';
return () => html`
<x-row gap="4" style="padding:1rem;" align="start">
<div>
<p style="font-size:.75rem;color:var(--md-color-text-muted);margin-bottom:.5rem;">checkType: none</p>
<div style=${box}>${PopupList({ items: ROLES, selected: noneS(), checkType: 'none', autoFocus: false, onSelect: ids => noneS.set(ids) })}</div>
</div>
<div>
<p style="font-size:.75rem;color:var(--md-color-text-muted);margin-bottom:.5rem;">checkType: radio</p>
<div style=${box}>${PopupList({ items: TAGS, selected: radioS(), checkType: 'radio', autoFocus: false, onSelect: ids => radioS.set(ids) })}</div>
</div>
<div>
<p style="font-size:.75rem;color:var(--md-color-text-muted);margin-bottom:.5rem;">checkType: check</p>
<div style=${box}>${PopupList({ items: TAGS, selected: checkS(), checkType: 'check', autoFocus: false, onSelect: ids => checkS.set(ids) })}</div>
</div>
</x-row>

PopupListItem — all states

import { html, renderApp } from 'mates';
import { PopupListItem } from 'mates-ui';
import { PersonIcon, SettingsIcon, NotificationsIcon, ChevronRightIcon, BlockIcon, LogoutIcon } from 'mates-icons';

const App = () => () => html`
<div style="padding:1rem;">
<div style="border:1px solid var(--md-color-border);border-radius:var(--md-radius-lg);overflow:hidden;background:var(--md-color-surface-raised);box-shadow:var(--md-shadow-1);max-width:18rem;">
[object Object]
[object Object]
[object Object]
[object Object]
[object Object]
[object Object]
[object Object]
[object Object]
</div>
</div>
`;
renderApp(App, document.getElementById('app'));

Grouped items

import { html, atom, renderApp } from 'mates';
import { Select } from 'mates-ui';
import { CodeIcon, DnsIcon } from 'mates-icons';

const GROUPED_ITEMS = [
{ id: '_h1', label: 'Frontend', type: 'header' },
{ id: 'react', label: 'React', icon: CodeIcon() },
{ id: 'vue', label: 'Vue', icon: CodeIcon() },
{ id: 'svelte', label: 'Svelte', icon: CodeIcon() },
{ id: '_h2', label: 'Backend', type: 'header' },
{ id: 'node', label: 'Node.js', icon: DnsIcon() },
{ id: 'python', label: 'Python', icon: DnsIcon() },
{ id: 'go', label: 'Go', icon: DnsIcon() },
];

const App = () => {
const sel = atom([]);
return () => html`
<div style="max-width:18rem;padding:1rem;">
${Select({ items: GROUPED_ITEMS, label: 'Framework', placeholder: 'Choose a framework', helper: 'Grouped by category', selected: sel(), onChange: ids => sel.set(ids) })}
${sel().length ? html`<p style="margin-top:.5rem;font-size:.75rem;color:var(--md-color-text-muted);">Selected: <strong>${sel()[0]}</strong></p>` : ''}
</div>
`;
};
renderApp(App, document.getElementById('app'));

Virtualised — plain (2 000 items with state)

import { html, atom, renderApp } from 'mates';
import { PopupListVirtualised } from 'mates-ui';

const LARGE_LIST = Array.from({ length: 2000 }, (_, i) => ({
id: 'item-' + i,
label: 'Item ' + (i + 1),
subLabel: 'Row index ' + i,
icon: i % 3 === 0 ? 'star' : i % 3 === 1 ? 'circle' : 'favorite',
}));

const App = () => {
const sel = atom([]);
const box = 'border:1px solid var(--md-color-border);border-radius:var(--md-radius-lg);overflow:hidden;background:var(--md-color-surface-raised);box-shadow:var(--md-shadow-1);width:16rem;';
return () => html`
<div style="padding:1rem;">
<div style=${box}>${PopupListVirtualised({ items: LARGE_LIST, selected: sel(), checkType: 'none', autoFocus: false, onSelect: ids => sel.set(ids) })}</div>
<p style="margin-top:.5rem;font-size:.75rem;color:var(--md-color-text-muted);">${sel().length ? 'Selected: ' + sel()[0] : 'None selected'}</p>
</div>
`;
};
renderApp(App, document.getElementById('app'));

Virtualised — multi-select checkbox (2 000 items)

import { html, atom, renderApp } from 'mates';
import { PopupListVirtualised } from 'mates-ui';

const LARGE_LIST = Array.from({ length: 2000 }, (_, i) => ({
id: 'item-' + i,
label: 'Item ' + (i + 1),
subLabel: 'Row index ' + i,
icon: i % 3 === 0 ? 'star' : i % 3 === 1 ? 'circle' : 'favorite',
}));

const App = () => {
const sel = atom([]);
const box = 'border:1px solid var(--md-color-border);border-radius:var(--md-radius-lg);overflow:hidden;background:var(--md-color-surface-raised);box-shadow:var(--md-shadow-1);width:16rem;';
return () => html`
<div style="padding:1rem;">
<div style=${box}>${PopupListVirtualised({ items: LARGE_LIST, selected: sel(), checkType: 'check', checkPosition: 'start', autoFocus: false, onSelect: ids => sel.set(ids) })}</div>
<p style="margin-top:.5rem;font-size:.75rem;color:var(--md-color-text-muted);">${sel().length} of 2000 selected</p>
</div>
`;
};
renderApp(App, document.getElementById('app'));

Virtualised — grouped (4 groups × 500 items)

import { html, atom, renderApp } from 'mates';
import { PopupListVirtualised } from 'mates-ui';

const LARGE_LIST = Array.from({ length: 2000 }, (_, i) => ({
id: 'item-' + i,
label: 'Item ' + (i + 1),
subLabel: 'Row index ' + i,
icon: i % 3 === 0 ? 'star' : i % 3 === 1 ? 'circle' : 'favorite',
}));

const App = () => {
const sel = atom([]);
const box = 'border:1px solid var(--md-color-border);border-radius:var(--md-radius-lg);overflow:hidden;background:var(--md-color-surface-raised);box-shadow:var(--md-shadow-1);width:16rem;';
return () => html`
<div style="padding:1rem;">
<div style=${box}>${PopupListVirtualised({ groups: [
{ label: 'Frontend', items: LARGE_LIST.slice(0, 500) },
{ label: 'Backend', items: LARGE_LIST.slice(500, 1000) },
{ label: 'DevOps', items: LARGE_LIST.slice(1000, 1500) },
{ label: 'Data', items: LARGE_LIST.slice(1500, 2000) },
], selected: sel(), checkType: 'check', autoFocus: false, onSelect: ids => sel.set(ids) })}</div>
<p style="margin-top:.5rem;font-size:.75rem;color:var(--md-color-text-muted);">${sel().length} of 2000 selected</p>
</div>
`;
};
renderApp(App, document.getElementById('app'));

Open on hover

import { html, atom, renderApp } from 'mates';
import { Select } from 'mates-ui';
import { FlagIcon, AdminPanelSettingsIcon, EditIcon, VisibilityIcon } from 'mates-icons';

const COUNTRIES = [
{ id: 'us', label: 'United States', icon: FlagIcon(), subLabel: 'North America' },
{ id: 'uk', label: 'United Kingdom', icon: FlagIcon(), subLabel: 'Europe' },
{ id: 'de', label: 'Germany', icon: FlagIcon(), subLabel: 'Europe' },
{ id: 'fr', label: 'France', icon: FlagIcon(), subLabel: 'Europe' },
];

const App = () => {
const sel = atom([]);
return () => html`
<x-row gap="4" style="max-width:28rem;padding:1rem;" align="start">
${Select({ items: COUNTRIES, label: 'Hover to open', placeholder: 'Hover over me…', openOnHover: true, selected: sel(), onChange: ids => sel.set(ids) })}
${Select({ items: COUNTRIES, label: 'Filled — hover to open', variant: 'filled', placeholder: 'Hover over me…', openOnHover: true, selected: sel(), onChange: ids => sel.set(ids) })}
</x-row>
`;
};
renderApp(App, document.getElementById('app'));

Hide chevron

import { html, atom, renderApp } from 'mates';
import { Select } from 'mates-ui';
import { FlagIcon, AdminPanelSettingsIcon, EditIcon, VisibilityIcon } from 'mates-icons';

const COUNTRIES = [
{ id: 'us', label: 'United States', icon: FlagIcon(), subLabel: 'North America' },
{ id: 'uk', label: 'United Kingdom', icon: FlagIcon(), subLabel: 'Europe' },
{ id: 'de', label: 'Germany', icon: FlagIcon(), subLabel: 'Europe' },
];
const ROLES = [
{ id: 'admin', label: 'Admin', icon: AdminPanelSettingsIcon(), subLabel: 'Full access' },
{ id: 'editor', label: 'Editor', icon: EditIcon(), subLabel: 'Can edit' },
{ id: 'viewer', label: 'Viewer', icon: VisibilityIcon(), subLabel: 'Read only' },
];

const App = () => {
const sel = atom([]);
return () => html`
<x-row gap="4" style="max-width:28rem;padding:1rem;" align="start">
${Select({ items: COUNTRIES, label: 'No chevron — outlined', placeholder: 'Choose a country…', hideChevron: true, selected: sel(), onChange: ids => sel.set(ids) })}
${Select({ items: ROLES, label: 'No chevron — filled', variant: 'filled', placeholder: 'Choose a role…', hideChevron: true, selected: sel(), onChange: ids => sel.set(ids) })}
</x-row>
`;
};
renderApp(App, document.getElementById('app'));

Select

import { Select } from "mates-ui";
Prop Type Default Description
attr AttrMap HTML attributes on the root element.
autoComplete boolean false Renders a search input at the top of the dropdown that filters items in real-time by label, subLabel, and icon.
bind AtomType<PopupListItemData[]> | FormAtom<PopupListItemData[]> Reactive binding — pass an atom or form-atom to sync the selected value.
classes string | string[] Extra root classes — joined and appended after built-in classes.
data DataAttrMap Root `data-*` map (`testId` → `data-testid`). `testId` prop / `attr` win over `data`.
disabled boolean Prevents interaction and dims the control.
emptyText string Text shown in the panel when there are no items (or no search results).
error boolean Puts the field into an error state, styling the trigger and helper text accordingly.
groups SelectGroup[] Hierarchical groups of options. When set, overrides flat items.
helper string Helper text shown below the trigger when there is no error.
hideChevron boolean Hides the chevron icon in the trigger.
id string Root `id` shorthand (`attr.id` wins). Form native-control `id` props are unchanged.
items* PopupListItemData[] Flat array of selectable options.
label string Floating label rendered above the trigger.
loading boolean Shows skeleton rows in the panel instead of items.
maxHeight string CSS max-height for the dropdown panel (e.g. '16rem').
maxVisibleTags number Maximum number of selected-item tags shown in the trigger before a '+N' overflow indicator appears.
multiple boolean false Allows selecting more than one item at a time.
on OnEventMap DOM event handlers on the root element.
onChange (id: string) => void Fired when the user selects an item. Receives the selected item id.
openOnHover boolean Opens the dropdown panel on hover instead of click.
placeholder string Placeholder text shown when nothing is selected.
popupMinWidth string CSS min-width for the popup panel. When set, disables matchAnchorWidth so the panel can be wider than the trigger (e.g. '16rem' for a narrow phone-input country picker).
removeOptionLabel string "Remove {label}" Remove-chip aria-label template; `{label}` is replaced with the item label.
renderTriggerValue (selected: PopupListItemData[]) => TemplateResult Custom renderer for the trigger value area. Overrides the default placeholder / label / tags display. Receives the currently-selected items.
searchPlaceholder string "Search…" Placeholder text for the built-in search input. Only used when autoComplete is true.
selected string Controlled selection — selected item id.
size "sm" | "md" | "lg" "md" Height / font-size scale of the trigger.
skeletonCount number Number of skeleton rows shown in the panel while loading, before items are rendered.
style StyleMap Shorthand inline styles applied to the root element.
style StyleMap Inline CSS on the root element.
testId string Data-testid attribute forwarded to the root element for testing.
variant "outlined" | "filled" "outlined" Visual style of the trigger field.

CSS tokens

Token Role
--md-color-border Trigger border colour (outlined variant).
--md-color-error Border and label colour in error state.
--md-color-primary Focus ring and active-state indicator.
--md-color-surface-raised Trigger background (filled variant) and dropdown panel background.
--md-shadow-dropdown Shadow applied to the floating panel.

Caveats

  • `onChange` receives a string[] even for single-select — read `ids[0]`.
  • `loading: true` shows skeleton rows in the panel instead of items.
  • For very large lists (500+) use `PopupListVirtualised` directly instead of `Select`.