{"id":51440069,"url":"https://github.com/alexstep/custom-select","last_synced_at":"2026-07-05T10:30:46.230Z","repository":{"id":361311216,"uuid":"1253977345","full_name":"alexstep/custom-select","owner":"alexstep","description":"Select web-component, like native","archived":false,"fork":false,"pushed_at":"2026-06-12T23:27:13.000Z","size":133,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-13T01:13:41.258Z","etag":null,"topics":["custom-select","select-component","select-filter","select-multiple","vanillajs","web-component"],"latest_commit_sha":null,"homepage":"https://alexstep.github.io/custom-select/demo.html","language":"JavaScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/alexstep.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-30T02:15:46.000Z","updated_at":"2026-06-12T23:27:17.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/alexstep/custom-select","commit_stats":null,"previous_names":["alexstep/custom-select"],"tags_count":1,"template":false,"template_full_name":null,"purl":"pkg:github/alexstep/custom-select","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/alexstep%2Fcustom-select","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/alexstep%2Fcustom-select/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/alexstep%2Fcustom-select/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/alexstep%2Fcustom-select/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/alexstep","download_url":"https://codeload.github.com/alexstep/custom-select/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/alexstep%2Fcustom-select/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35151638,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-07-05T02:00:06.290Z","response_time":100,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["custom-select","select-component","select-filter","select-multiple","vanillajs","web-component"],"created_at":"2026-07-05T10:30:46.144Z","updated_at":"2026-07-05T10:30:46.209Z","avatar_url":"https://github.com/alexstep.png","language":"JavaScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Pure Custom Select\n\n[![npm](https://img.shields.io/npm/v/pure-custom-select.svg)](https://www.npmjs.com/package/pure-custom-select)\n[![MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)\n\nA `\u003cselect\u003e` replacement as a native web component. No dependencies, no build step, no framework.\n\n- Native form participation via `ElementInternals` — works inside `\u003cform\u003e`, FormData, validation.\n- Goes native on mobile: real `\u003cselect\u003e` or a bottom sheet. Desktop gets a custom popup.\n- Keyboard: arrows, Home/End, typeahead, Enter/Esc. ARIA combobox/listbox roles.\n- Single \u0026 multiple selection, option groups, built-in filter, async search.\n- Themeable with plain CSS variables. Optional Shadow DOM isolation.\n\n[Live demo](https://alexstep.github.io/custom-select/demo.html) · ~10 KB initial gzipped (entry JS + CSS, see [Size](#size)).\n\n## Install\n\n```bash\nnpm install pure-custom-select\n```\n\n```js\nimport 'pure-custom-select' // registers \u003ccustom-select\u003e\n```\n\nRegistration is idempotent. Importing the package more than once (HMR, micro-frontends, several copies of the package) will not throw `NotSupportedError`; the duplicate `define` is skipped. To register under a different tag name, call the exported helper:\n\n```js\nimport { defineCustomSelect } from 'pure-custom-select'\ndefineCustomSelect('my-select') // returns true if it registered, false if already defined\n```\n\nOr without a bundler (pre-built `dist/`, all chunk files must be reachable):\n\n```html\n\u003clink rel=\"stylesheet\" href=\"https://unpkg.com/pure-custom-select@1/dist/custom-select.min.css\"\u003e\n\u003cscript type=\"module\"\u003e\n  import CustomSelect from 'https://unpkg.com/pure-custom-select@1/dist/custom-select.min.js'\n  customElements.define('custom-select', CustomSelect)\n\u003c/script\u003e\n```\n\nIf you import the class yourself, register it manually (guard against a double `define`):\n\n```js\nimport CustomSelect from 'pure-custom-select/custom-select.js'\nif (!customElements.get('custom-select')) {\n  customElements.define('custom-select', CustomSelect)\n}\n```\n\nStyles are imported by `index.js`. When loading files directly, include them yourself:\n\n```html\n\u003clink rel=\"stylesheet\" href=\"pure-custom-select/styles/base.css\"\u003e\n\u003clink rel=\"stylesheet\" href=\"pure-custom-select/styles/themes.css\"\u003e\n\u003clink rel=\"stylesheet\" href=\"pure-custom-select/styles/mobile.css\"\u003e\n```\n\n**Load CSS before JS** when using `\u003cscript type=\"module\"\u003e` or a CDN bundle. [`base.css`](styles/base.css) hides light-DOM `\u003coption\u003e` / `\u003coptgroup\u003e` and reserves trigger height until the element upgrades (`:not(:defined)`) and until the first render finishes (`:not([data-cs-ready])`) — this prevents a flash of expanded options and layout shift on slow connections. With a bundler, `import 'pure-custom-select'` pulls CSS in with the JS entry; for zero flash on first paint, add `\u003clink rel=\"stylesheet\" href=\"…/custom-select.min.css\"\u003e` in `\u003chead\u003e` ahead of the module script.\n\n## Usage\n\n```html\n\u003ccustom-select name=\"fruit\" placeholder=\"Pick a fruit…\"\u003e\n  \u003coption value=\"apple\"\u003eApple\u003c/option\u003e\n  \u003coption value=\"banana\" selected\u003eBanana\u003c/option\u003e\n  \u003coption value=\"cherry\"\u003eCherry\u003c/option\u003e\n\u003c/custom-select\u003e\n```\n\nMultiple + filter + groups:\n\n```html\n\u003ccustom-select name=\"city\" multiple searchable placeholder=\"Cities…\"\u003e\n  \u003coptgroup label=\"Europe\"\u003e\n    \u003coption value=\"lon\"\u003eLondon\u003c/option\u003e\n    \u003coption value=\"par\"\u003eParis\u003c/option\u003e\n  \u003c/optgroup\u003e\n  \u003coptgroup label=\"Asia\"\u003e\n    \u003coption value=\"tok\"\u003eTokyo\u003c/option\u003e\n  \u003c/optgroup\u003e\n\u003c/custom-select\u003e\n```\n\nRead the value (string, or array when `multiple`):\n\n```js\nconst el = document.querySelector('custom-select[name=fruit]')\nel.addEventListener('change', e =\u003e console.log(e.detail.value))\n```\n\nSet options imperatively instead of `\u003coption\u003e`:\n\n```js\nel.items = [\n  { value: 'a', label: 'Alpha' },\n  { value: 'b', label: 'Beta', disabled: true },\n]\n```\n\nAsync search has two modes.\n\nLocal (default) — `onsearch` returns the subset of the **already-declared** options that match. Matching is done by `value`, so options the server does not know about stay hidden:\n\n```js\nel.searchable = true\nel.onsearch = async (query, { signal }) =\u003e {\n  const res = await fetch(`/api/search?q=${query}`, { signal })\n  return res.json() // [{ value }] referencing existing \u003coption\u003es\n}\n```\n\nRemote (`search-mode=\"remote\"`) — `onsearch` returns the full list to display. Items that were never present as `\u003coption\u003e` children are rendered, and when one is picked it is registered on the component (label shown, value submitted with the form):\n\n```html\n\u003ccustom-select searchable search-mode=\"remote\" name=\"city\"\u003e\u003c/custom-select\u003e\n```\n\n```js\nel.onsearch = async (query, { signal }) =\u003e {\n  const res = await fetch(`/api/cities?q=${query}`, { signal })\n  return res.json() // [{ value, label, disabled? }] — may include brand-new options\n}\n```\n\n## Frameworks (React, Angular, Vue)\n\nThe component is a standard custom element. Import once so it registers, then use the tag in templates. **Set `value` and complex props via the DOM property**, not attributes (especially for `multiple` and arrays).\n\n```js\nimport 'pure-custom-select'\n```\n\n### React\n\n```jsx\nimport { useEffect, useRef } from 'react'\nimport 'pure-custom-select'\n\nexport function FruitSelect({ value, onChange }) {\n  const ref = useRef(null)\n\n  useEffect(() =\u003e {\n    const el = ref.current\n    if (!el) return\n    el.value = value\n    const onSelectChange = e =\u003e onChange(e.detail.value)\n    el.addEventListener('change', onSelectChange)\n    return () =\u003e el.removeEventListener('change', onSelectChange)\n  }, [value, onChange])\n\n  return (\n    \u003ccustom-select ref={ref} name=\"fruit\" placeholder=\"Pick a fruit…\"\u003e\n      \u003coption value=\"apple\"\u003eApple\u003c/option\u003e\n      \u003coption value=\"banana\"\u003eBanana\u003c/option\u003e\n    \u003c/custom-select\u003e\n  )\n}\n```\n\nFor `multiple`, pass an array to `el.value`. With SSR, render the tag on the client only or after `customElements.whenDefined('custom-select')`.\n\n### Angular\n\n```typescript\nimport { Component, CUSTOM_ELEMENTS_SCHEMA, ElementRef, ViewChild, effect, input, output } from '@angular/core'\nimport 'pure-custom-select'\n\n@Component({\n  selector: 'app-fruit-select',\n  schemas: [CUSTOM_ELEMENTS_SCHEMA],\n  template: `\n    \u003ccustom-select #sel name=\"fruit\" placeholder=\"Pick a fruit…\"\u003e\n      \u003coption value=\"apple\"\u003eApple\u003c/option\u003e\n      \u003coption value=\"banana\"\u003eBanana\u003c/option\u003e\n    \u003c/custom-select\u003e\n  `,\n})\nexport class FruitSelectComponent {\n  value = input\u003cstring\u003e('')\n  valueChange = output\u003cstring\u003e()\n\n  @ViewChild('sel', { static: true }) sel!: ElementRef\u003cHTMLElement \u0026 { value: string }\u003e\n\n  constructor() {\n    effect(() =\u003e {\n      this.sel.nativeElement.value = this.value()\n    })\n  }\n\n  ngAfterViewInit() {\n    this.sel.nativeElement.addEventListener('change', (e: Event) =\u003e {\n      const detail = (e as CustomEvent\u003c{ value: string }\u003e).detail\n      this.valueChange.emit(detail.value)\n    })\n  }\n}\n```\n\nAdd `CUSTOM_ELEMENTS_SCHEMA` to the component (or module) that uses `\u003ccustom-select\u003e`.\n\n### Vue 3\n\n```vue\n\u003cscript setup\u003e\nimport { ref, watch, onMounted, onBeforeUnmount } from 'vue'\nimport 'pure-custom-select'\n\nconst props = defineProps({ modelValue: { type: String, default: '' } })\nconst emit = defineEmits(['update:modelValue'])\n\nconst el = ref(null)\n\nfunction syncToElement() {\n  if (el.value) el.value.value = props.modelValue\n}\n\nfunction onChange(e) {\n  emit('update:modelValue', e.detail.value)\n}\n\nonMounted(() =\u003e {\n  syncToElement()\n  el.value?.addEventListener('change', onChange)\n})\n\nonBeforeUnmount(() =\u003e el.value?.removeEventListener('change', onChange))\n\nwatch(() =\u003e props.modelValue, syncToElement)\n\u003c/script\u003e\n\n\u003ctemplate\u003e\n  \u003ccustom-select ref=\"el\" name=\"fruit\" placeholder=\"Pick a fruit…\"\u003e\n    \u003coption value=\"apple\"\u003eApple\u003c/option\u003e\n    \u003coption value=\"banana\"\u003eBanana\u003c/option\u003e\n  \u003c/custom-select\u003e\n\u003c/template\u003e\n```\n\n`vite.config` does not need special handling if you import `pure-custom-select` (CSS is a side effect). For `multiple`, bind `modelValue` as an array and assign `el.value.value = [...]`.\n\n## API\n\n### Attributes / properties\n\n| Name | Type | Default | Notes |\n| --- | --- | --- | --- |\n| `name` | string | `''` | Form field name. |\n| `value` | string \\| string[] | `''` | Array when `multiple`. |\n| `multiple` | boolean | `false` | Multi-select. |\n| `placeholder` | string | `''` | Shown when nothing is selected. |\n| `theme` | `light` \\| `dark` \\| `auto` | `auto` | `auto` follows `prefers-color-scheme`. |\n| `mobileview` | `native` \\| `native-multiple` \\| `sheet` \\| `desktop` | `native` | Mobile rendering mode. |\n| `searchable` | boolean | `false` | Adds a filter input to the popup. |\n| `search-placeholder` | string | `''` | Filter input placeholder. |\n| `search-mode` | `local` \\| `remote` | `local` | `remote` renders `onsearch` results wholesale (see [Async search](#frameworks-react-angular-vue)). |\n| `disabled` | boolean | `false` | Blocks interaction. |\n| `noscroll` | boolean | `false` | Disables popup internal scroll. |\n| `shadow-dom` | boolean | `false` | Render inside a Shadow DOM. |\n| `no-sheet-history` | boolean | `false` | Disables the mobile sheet's `history.pushState` back-button integration. |\n\nProperty-only:\n\n| Property | Type | Notes |\n| --- | --- | --- |\n| `items` | `{value, label, disabled?, labelText?}[]` | Set options from JS. |\n| `onsearch` | `(query, {signal}) =\u003e Promise\u003citems\u003e` | Async search callback. |\n| `searchMode` | `'local' \\| 'remote'` | Mirrors the `search-mode` attribute. |\n| `noSheetHistory` | boolean | Mirrors the `no-sheet-history` attribute. |\n| `themes` | string[] | Available themes (read-only). |\n| `mobileviews` | string[] | Available mobile modes (read-only). |\n| `searchModes` | string[] | Available search modes (read-only). |\n\n### Events\n\n| Event | `detail` | When |\n| --- | --- | --- |\n| `change` | `{ value }` | Value changes. |\n| `popup-open` | `{}` | Desktop popup opens. |\n| `popup-close` | `{}` | Desktop popup closes (cancelable). |\n| `filter-change` | `{ query, results }` | Filter input changes. |\n\n## Mobile bottom sheet\n\nOn mobile, opening the sheet pushes a history entry (`#custom-select-open`) so the hardware back button closes it instead of navigating away. Inside a hash router, a Telegram Mini App, or a deep-link/analytics setup this hash change is an unexpected side effect. Disable it with `no-sheet-history`:\n\n```html\n\u003ccustom-select mobileview=\"sheet\" no-sheet-history\u003e\u003c/custom-select\u003e\n```\n\n## Theming\n\nOverride CSS variables on the element (or any ancestor). Defaults fall back to Telegram Mini App theme variables, then to sensible values.\n\n```css\ncustom-select {\n  --cs-bg: #fff;            /* trigger / popup background */\n  --cs-text: #111;          /* text color */\n  --cs-border: #e2e8f0;     /* border color */\n  --cs-border-radius: 10px; /* corner radius */\n  --cs-min-width: 240px;    /* min trigger width */\n  --cs-accent-bg: #2563eb;  /* focused/selected option background */\n  --cs-accent-text: #fff;   /* focused/selected option text */\n  --cs-muted-text: #94a3b8; /* placeholder / hints */\n  --cs-animation-duration: .25s;\n}\n```\n\nThe desktop popup merges with the trigger into a single block and opens up or down depending on available space. The toggle arrow rotates on open and both the arrow and options have a press (scale) feedback. All transitions respect `prefers-reduced-motion`.\n\n## Browser support\n\nLatest Chrome, Firefox, Safari, Edge. Requires Custom Elements v1, `ElementInternals`, and `\u003cdialog\u003e`.\n\n## Size\n\n`npm run build` emits **code-split** chunks (`--splitting`): the browser loads `custom-select.min.js` first; `desktop-popup`, `mobile-sheet`, and `filter-module` chunks load on first open. CSS is `base` + `mobile` + `themes` combined.\n\n| What loads | JS (min) | JS (gzip) |\n|------------|----------|-----------|\n| **Initial** (entry only) | 24.65 KB | 7.69 KB |\n| + desktop popup (first open) | +4.93 KB | +2.06 KB |\n| + filter (`searchable`, first open) | +2.41 KB | +1.22 KB |\n| + mobile sheet (first open) | +5.55 KB | +2.19 KB |\n| **All JS chunks** | 38.46 KB | 13.90 KB |\n| **CSS** (`custom-select.min.css`) | 14.10 KB | 3.20 KB |\n\n**Typical first paint:** entry JS + CSS = **10.89 KB gzip**.\n\nPre-built `dist/`: ship **all** `*.min.js` files together (chunk hashes are stable per build). Register from the entry bundle:\n\n```js\nimport CustomSelect from './dist/custom-select.min.js'\nimport './dist/custom-select.min.css'\ncustomElements.define('custom-select', CustomSelect)\n```\n\nSource `import 'pure-custom-select'` keeps the same lazy `import()` boundaries; your bundler splits chunks automatically.\n\n## TypeScript\n\nTypes ship in `types/` (`types/index.d.ts`, `types/custom-select.d.ts`).\n\n```ts\nimport 'pure-custom-select'\nimport type { CustomSelectItem, CustomSelectChangeEventDetail } from 'pure-custom-select'\n\nconst el = document.querySelector('custom-select')!\nel.addEventListener('change', (e: CustomEvent\u003cCustomSelectChangeEventDetail\u003e) =\u003e {\n  console.log(e.detail.value)\n})\n```\n\nHand-written declarations are the source of truth. Optional: `npm run types:emit-from-js` runs `tsc` on `custom-select.js` (noisy output in `types-gen/`, for diff only). `npm run types:check` validates `.d.ts`.\n\n## Development\n\n```bash\nnpm run types:check\nnpm run build   # minify + gzip + brotli → dist/\n```\n\nSource layout: `core/` (state, parsing, form), `ui/` (desktop popup, mobile sheet), `keyboard/`, `filter/`, `utils/`, `styles/`.\n\n## License\n\nMIT\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Falexstep%2Fcustom-select","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Falexstep%2Fcustom-select","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Falexstep%2Fcustom-select/lists"}