npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

bie-dropdown

v0.2.4

Published

Searchable dropdown web component built with Lit

Readme

bie-dropdown

Searchable dropdown web component built with Lit, using native Popover API and CSS Anchor Positioning.

  • 🪶 19.8 kB bundled (5.0 kB gzipped) + lit as peer dependency
  • 🔍 Built-in search/filter
  • 🎯 Native popover — no z-index hacks
  • ⌨️ Full keyboard navigation (↑↓ Enter Escape)
  • 📱 Responsive — fullscreen bottom sheet on mobile
  • 🎨 Customizable via CSS parts and custom properties
  • 📦 Framework-agnostic — works with React, Vue, Angular, or vanilla HTML
  • 🔗 Cascading / dependent dropdown support — child dropdown waits for parent selection

Install

npm install bie-dropdown lit

Quick Start

<bie-dropdown
  searchable
  label-key="name"
  search-keys="name,email"
  placeholder="Select a user..."
  search-placeholder="Search..."
></bie-dropdown>

<script type="module">
  import 'bie-dropdown';

  const dropdown = document.querySelector('bie-dropdown');
  dropdown.items = [
    { id: 1, name: 'John Doe', email: '[email protected]' },
    { id: 2, name: 'Jane Smith', email: '[email protected]' },
  ];

  dropdown.addEventListener('change', (e) => {
    console.log('Selected:', e.target.selected); // the full item object
    console.log('Value:', e.target.value);       // "1" (uses valueKey default "id")
  });
</script>

API

Properties

| Property | Attribute | Type | Default | Description | |---|---|---|---|---| | items | — | Array<object> \| (query, parentValue?) => items | [] | Static array or async loader function | | labelKey | label-key | string | "name" | Object key used for display text (ignored if labelTemplate is set) | | labelTemplate | label-template | string | "" | Template string for display label (e.g. "{name} ({email})"). Supports nested fields up to depth 2. | | valueKey | value-key | string | "id" | Object key used for the value property. Read by wire:model. | | value | — | string \| null | — | Read/write string representation of current selection. Derived from selected[valueKey]. | | searchKeys | search-keys | string | "" | Comma-separated keys to search against (only for static arrays) | | placeholder | placeholder | string | "Select..." | Text shown on trigger when nothing is selected | | searchPlaceholder | search-placeholder | string | "Search..." | Placeholder for the search input | | searchable | searchable | boolean | true | Show/hide the search input | | searchDebounce | search-debounce | number | 300 | Debounce delay in ms (only for async loader) | | selected | — | object \| null | null | Currently selected item. Use value + change event for form binding. | | dependsOn | depends-on | string | "" | CSS selector for a parent dropdown this one depends on | | parentKey | parent-key | string | "" | Key to extract value from parent's selected item (if empty, full object is passed) | | emptyMessage | empty-message | string | "" | Message shown when parent is not selected yet |

Events

| Event | Description | |---|---| | input | Standard input event. Fires on every selection/clear. Used by wire:model.live. | | change | Standard change event. Fires on every selection/clear. Used by wire:model. | | selected (property) | Access e.target.selected for the full item object, or e.target.value for the string value. |

Methods

| Method | Description | |---|---| | show() | Programmatically open the dropdown | | hide() | Programmatically close the dropdown | | clear() | Reset selection to nothing | | isOpen() | Returns true if dropdown popover is open | | reload() | Manually reload items (useful with async loader) |

Async Loader

Pass a function to items for server-side search. The function receives the current search query and returns items (sync or async).

const dropdown = document.querySelector('bie-dropdown');

// Real API example (JSONPlaceholder)
dropdown.items = async (query) => {
  const res = await fetch('https://jsonplaceholder.typicode.com/users');
  const users = await res.json();

  // Filter locally (or pass `query` to your API)
  return users.filter(
    (u) =>
      u.name.toLowerCase().includes(query) ||
      u.email.toLowerCase().includes(query)
  );
};

Behavior:

  • Called on popover open with query = ''
  • Called on search input with debounce (default 300 ms, configurable via search-debounce)
  • Previous in-flight request is automatically cancelled (AbortController)
  • Loading state shown via part="loading" (spinner + text)
  • Errors shown via part="error"

With debounce control:

<bie-dropdown search-debounce="500"></bie-dropdown>

Custom Option Template

Customise how each option is rendered by placing a <template> inside <bie-dropdown>.

<bie-dropdown>
  <template slot="option-template">
    <div part="option-item">
      <p part="option-item-name">{name}</p>
      <p part="option-item-email">{email}</p>
    </div>
  </template>
</bie-dropdown>

<script type="module">
  import 'bie-dropdown';

  const dropdown = document.querySelector('bie-dropdown');
  dropdown.items = [
    { id: 1, name: 'John Doe', email: '[email protected]' },
    { id: 2, name: 'Jane Smith', email: '[email protected]' },
  ];
</script>

Notes:

  • Placeholder {fieldName} is replaced from item data. Nested fields ({address.city}) are supported up to depth 2.
  • Replacement only works on text nodes, not HTML attributes.
  • The template root element must have part="option-item" to be picked up by the component.
  • Style the template elements via CSS parts (e.g., bie-dropdown::part(option-item-name)) or inline styles — class-based styling from outside the shadow DOM does not apply.
  • Checkmark slot: add <span slot="option-check">...</span> in light DOM to customise the selected indicator.

Styling the custom template:

bie-dropdown::part(option-item-name) {
  font-weight: 600;
}

bie-dropdown::part(option-item-email) {
  font-size: 0.75rem;
  color: #6b7280;
}

Cascading Dropdown

Link a child dropdown to a parent dropdown so that the child waits for the parent selection and reloads automatically.

<bie-dropdown id="country" placeholder="Select Country..."></bie-dropdown>
<bie-dropdown
  id="city"
  placeholder="Select City..."
  depends-on="#country"
  parent-key="id"
  empty-message="Please select a country first"
></bie-dropdown>
const city = document.getElementById('city');
city.items = async (query, countryId) => {
  if (!countryId) return [];
  const res = await fetch(`/api/cities?country=${countryId}&q=${query}`);
  return res.json();
};

Behavior:

  • When dependsOn is set, the child dropdown listens for change on the parent.
  • When the parent changes, the child auto-clears its selection and reloads with the new parent value.
  • The async loader receives the parent value as the second argument (parentValue).
  • If parentKey is set, only that property is extracted from the parent's selected item. If empty, the full selected object is passed.
  • While the parent has no selection, the popover still opens but shows emptyMessage.
  • Caching: full results are cached per parent value. Re-opening the dropdown with the same parent selection does not refetch — cached data is displayed instantly. Search input and parent changes always fetch fresh data.

Alpine.js Example

<div x-data="{ selectedCountry: null }">
  <bie-dropdown
    id="country"
    placeholder="Select Country..."
    x-init="$el.items = [{id:1,name:'USA'},{id:2,name:'UK'}]"
    @change="selectedCountry = $event.target.selected"
  ></bie-dropdown>

  <bie-dropdown
    id="city"
    placeholder="Select City..."
    depends-on="#country"
    parent-key="id"
    empty-message="Please select a country first"
  ></bie-dropdown>
</div>

<script>
  document.getElementById('city').items = async (query, countryId) => {
    if (!countryId) return [];
    // fetch cities...
  };
</script>

Styling

CSS Custom Properties

bie-dropdown {
  --bie-bg: #ffffff;
  --bie-border: #d1d5db;
  --bie-text: #111827;
  --bie-hover-bg: #f3f4f6;
  --bie-focus-ring: #3b82f6;
  --bie-radius: 0.5rem;
  --bie-max-width: 24rem;
}

Sizing

Popover automatically matches the trigger width via CSS Anchor Positioning. Use --bie-max-width to constrain the dropdown:

bie-dropdown {
  --bie-max-width: 24rem;
}

Or set min-width / max-width directly on the host element:

bie-dropdown {
  min-width: 12rem;
  max-width: 24rem;
}

Override the popover width independently via the popover part:

/* Popover wider than trigger (e.g. for long option text) */
bie-dropdown::part(popover) {
  width: 24rem;
  max-width: 90vw;
}

CSS Parts

bie-dropdown::part(summary) { }      /* Wrapper around trigger and reset button */
bie-dropdown::part(reset) { }        /* Button to clear the current selection */
bie-dropdown::part(trigger) { }      /* Trigger button */
bie-dropdown::part(arrow) { }        /* Dropdown arrow icon */
bie-dropdown::part(label) { }        /* Trigger label text */
bie-dropdown::part(popover) { }      /* Popover container */
bie-dropdown::part(popover-inner) { }/* Inner popover wrapper */
bie-dropdown::part(header) { }       /* Popover header row (mobile) */
bie-dropdown::part(header-title) { } /* Header title (mobile) */
bie-dropdown::part(close) { }        /* Close button */
bie-dropdown::part(search) { }       /* Search input wrapper */
bie-dropdown::part(search-input) { } /* Search input */
bie-dropdown::part(options) { }      /* Options list wrapper */
bie-dropdown::part(option) { }       /* Individual option */
bie-dropdown::part(empty) { }        /* Empty state message */
bie-dropdown::part(loading) { }      /* Loading indicator */
bie-dropdown::part(error) { }        /* Error state message */

Dark theme example

<bie-dropdown style="
  --bie-bg: #1e1e2e;
  --bie-border: #45475a;
  --bie-text: #cdd6f4;
  --bie-hover-bg: #313244;
  --bie-focus-ring: #cba6f7;
"></bie-dropdown>

Tailwind CSS (v4)

Style bie-dropdown with Tailwind v4's CSS-first approach via custom properties and parts.

Quick inline (arbitrary properties)

<bie-dropdown
  class="[--bie-bg:var(--color-white)] [--bie-border:var(--color-gray-300)]
         [--bie-text:var(--color-gray-900)] [--bie-hover-bg:var(--color-gray-100)]
         [--bie-focus-ring:var(--color-blue-500)] [--bie-radius:var(--radius-lg)]"
></bie-dropdown>

Dark mode with dark: variant

<bie-dropdown
  class="[--bie-bg:var(--color-white)] [--bie-text:var(--color-gray-900)]
         dark:[--bie-bg:var(--color-gray-900)] dark:[--bie-text:var(--color-gray-100)]
         dark:[--bie-border:var(--color-gray-700)] dark:[--bie-hover-bg:var(--color-gray-800)]"
></bie-dropdown>

Register design tokens with @theme

/* app.css */
@import "tailwindcss";

@theme {
  --color-bie-bg: #ffffff;
  --color-bie-border: #d1d5db;
  --color-bie-text: #111827;
  --color-bie-hover-bg: #f3f4f6;
  --color-bie-focus-ring: #3b82f6;
  --radius-bie: 0.5rem;
}
<bie-dropdown
  class="[--bie-bg:var(--color-bie-bg)] [--bie-border:var(--color-bie-border)]
         [--bie-text:var(--color-bie-text)] [--bie-hover-bg:var(--color-bie-hover-bg)]
         [--bie-focus-ring:var(--color-bie-focus-ring)] [--bie-radius:var(--radius-bie)]"
></bie-dropdown>

Customizing parts with global CSS

@import "tailwindcss";

@layer components {
  bie-dropdown::part(trigger) {
    @apply rounded-lg px-4 py-2.5 text-sm font-medium;
  }

  bie-dropdown::part(option) {
    @apply px-4 py-2 text-sm hover:bg-gray-100;
  }

  bie-dropdown::part(reset) {
    @apply text-gray-400 hover:text-red-500 transition-colors;
  }
}

Keyboard

| Key | Action | |---|---| | Arrow Down | Move highlight down / open if closed | | Arrow Up | Move highlight up | | Enter | Select highlighted option | | Escape | Close popover |

Browser Support

Requires Popover API and CSS Anchor Positioning:

| Browser | Version | |---|---| | Chrome | 114+ | | Edge | 114+ | | Safari | 17+ | | Firefox | 125+ |

Framework Integration

Alpine.js

<div x-data="{ users: [], selected: null }">
  <bie-dropdown
    x-ref="dd"
    x-effect="$refs.dd.items = users"
    @change="selected = $event.target.selected"
  ></bie-dropdown>

  <p x-show="selected" x-text="selected?.name"></p>
</div>

Patterns:

| Use case | Approach | |---|---| | Static items from Alpine data | x-effect="$refs.dd.items = users" | | Async loader from Alpine | x-init="$refs.dd.items = myLoaderFn" | | React to selection | @change="selected = $event.target.selected" |

Livewire 4.x

bie-dropdown is natively compatible with Livewire 4.x wire:model via standard input/change events and the value property.

{{-- resources/views/livewire/user-selector.blade.php --}}
<div>
    <bie-dropdown
        id="user-dd"
        wire:model="userId"
        value-key="id"
        label-template="{name} ({email})"
        placeholder="Select a user..."
    ></bie-dropdown>

    <p>Selected ID: {{ $userId }}</p>
</div>

@script
<script>
    document.getElementById('user-dd').items = @json($users);
</script>
@endscript
// app/Livewire/UserSelector.php
namespace App\Livewire;

use Livewire\Component;

class UserSelector extends Component
{
    public $userId = null;
    public $users = [];

    public function mount()
    {
        $this->users = User::select('id', 'name', 'email')->get()->toArray();
    }

    public function render()
    {
        return view('livewire.user-selector');
    }
}

With wire:model.live

{{-- Real-time updates as user selects --}}
<bie-dropdown
    wire:model.live="userId"
    value-key="id"
    placeholder="Search and select..."
></bie-dropdown>

Async Loader with Livewire

<div>
    <bie-dropdown
        id="user-search"
        wire:model.live="userId"
        value-key="id"
        placeholder="Type to search..."
        search-debounce="300"
    ></bie-dropdown>
</div>

@script
<script>
    const dd = document.getElementById('user-search');
    dd.items = async (query) => {
        return await $wire.searchUsers(query);
    };
</script>
@endscript
public function searchUsers($query)
{
    return User::where('name', 'like', "%{$query}%")
        ->select('id', 'name', 'email')
        ->get()
        ->toArray();
}

Cascading with Livewire

<div>
    <bie-dropdown
        id="lw-country"
        wire:model="countryId"
        value-key="id"
        placeholder="Select country..."
    ></bie-dropdown>

    <bie-dropdown
        wire:model="cityId"
        value-key="id"
        depends-on="#lw-country"
        parent-key="id"
        empty-message="Please select a country first"
        label-template="{name}, {countryName}"
        placeholder="Select city..."
    ></bie-dropdown>
</div>

@script
<script>
    document.getElementById('lw-country').items = @json($countries);

    document.querySelector('[depends-on="#lw-country"]').items = async (query, countryId) => {
        return await $wire.searchCities(query, countryId);
    };
</script>
@endscript

Programmatic value setter (bidirectional)

@script
<script>
    // Livewire sets value from server → dropdown auto-selects matching item
    $wire.$watch('userId', (value) => {
        document.querySelector('bie-dropdown').value = value;
    });

    // Dropdown selection → sync to Livewire
    document.querySelector('bie-dropdown').addEventListener('change', (e) => {
        // e.target.value already set — wire:model handles this automatically
    });
</script>
@endscript

React / Vue / Others

As a standard custom element, bie-dropdown works in any framework:

// React
<bie-dropdown ref={el => el && (el.items = data)} />

// Vue
<bie-dropdown :ref="el => el.items = data" />

Development

# Install dependencies
npm install

# Start dev server with hot reload
npm run dev

# Build for production
npm run build

# Preview production build
npm run preview

License

MIT