Skip to content
Devix Open Source

Customize

Styling and theming

Every visual decision can be changed, at whichever level suits the project:

  1. CSS variables. Colours, sizes, radii and weights — a few lines per brand.
  2. classNames. Your own classes on every part and state, for Tailwind, Bootstrap or utility CSS.
  3. Plain CSS. Stable dxs-* classes, is-* state classes and ARIA attributes.
  4. render and icons. Your own markup for a row, a chip, a heading or the empty state.
  5. Your own UI. @devix-labs/select/core gives you matching, ranking and grouping with no markup at all.

Stylesheets

import '@devix-labs/select/styles.css';        // plain
import '@devix-labs/select/styles.layer.css';  // the same, inside @layer devix

The layered build loses to any unlayered rule of yours, so app styles win without !important.

Variables

Set them on .dxs, .dxs-panel — the panel is portalled into the top layer, so it needs them too.

.dxs, .dxs-panel {
  --dxs-height: 36px;              /* control height */
  --dxs-radius: 8px;
  --dxs-font-size: 14px;
  --dxs-panel-font-size: 13px;
  --dxs-panel-radius: 10px;
  --dxs-panel-max-height: 320px;
  --dxs-option-height: 32px;       /* also the row height the virtual list uses */
  --dxs-group-height: 26px;
  --dxs-chip-height: 22px;
  --dxs-chip-radius: 5px;
  --dxs-gap: 5px;
  --dxs-font-weight-selected: 500;

  --dxs-bg: #fff;
  --dxs-text: #18181b;
  --dxs-muted: #71717a;
  --dxs-border: #e4e4e7;
  --dxs-border-hover: #c9c9d0;
  --dxs-accent: #ea4b71;           /* focus ring, tick, create row */
  --dxs-ring: color-mix(in srgb, var(--dxs-accent) 28%, transparent);
  --dxs-panel-bg: #fff;
  --dxs-panel-shadow: 0 12px 32px rgb(17 17 28 / .12);
  --dxs-option-active: #f4f4f5;
  --dxs-option-selected: color-mix(in srgb, var(--dxs-accent) 10%, transparent);
  --dxs-chip-bg: #f4f4f5;
  --dxs-chip-text: inherit;
  --dxs-mark-bg: color-mix(in srgb, var(--dxs-accent) 22%, transparent);
  --dxs-mark-text: inherit;
  --dxs-divider: #ececf1;
}

Changing --dxs-option-height is enough to make the list roomier; the windowed renderer reads it, so scrolling stays correct.

Dark mode

theme: 'auto' (the default) looks for .dark, data-theme, data-bs-theme, data-mode or data-color-scheme on an ancestor, and otherwise measures the background behind the field. It follows a theme toggle live. Force it with theme: 'dark', or write your own:

.dxs[data-theme='dark'], .dxs-panel[data-theme='dark'] { --dxs-panel-bg: #0b0b0f; }

Matching a design system

shadcn/ui

.dxs, .dxs-panel {
  --dxs-radius: var(--radius);
  --dxs-bg: hsl(var(--background));
  --dxs-text: hsl(var(--foreground));
  --dxs-border: hsl(var(--border));
  --dxs-accent: hsl(var(--ring));
  --dxs-panel-bg: hsl(var(--popover));
  --dxs-option-active: hsl(var(--accent));
}

Bootstrap 5

.dxs, .dxs-panel {
  --dxs-height: 38px;
  --dxs-radius: var(--bs-border-radius);
  --dxs-bg: var(--bs-body-bg);
  --dxs-text: var(--bs-body-color);
  --dxs-border: var(--bs-border-color);
  --dxs-accent: var(--bs-primary);
  --dxs-panel-bg: var(--bs-body-bg);
}

Material

.dxs { --dxs-radius: 4px; --dxs-height: 40px; --dxs-accent: #6750a4; --dxs-panel-radius: 4px; }
.dxs-control { box-shadow: inset 0 -1px 0 var(--dxs-border); border-radius: 4px 4px 0 0; }

Tailwind classes on every part

createSelect(el, {
  classNames: {
    control: 'rounded-lg border-zinc-200 shadow-sm',
    panel: 'rounded-xl shadow-xl ring-1 ring-black/5',
    option: 'rounded-md px-2',
    chip: 'bg-indigo-50 text-indigo-700',
    optionDescription: 'text-xs text-zinc-400',
  },
});

Parts: root, control, values, chip, chipLabel, chipRemove, input, single, placeholder, clear, chevron, panel, search, list, option, optionLabel, optionDescription, group, empty, loading, create, count.

Classes and states

Class When
.dxs--open The panel is open.
.dxs--filled Something is selected.
.dxs--multiple, .dxs--tags, .dxs--fixed Multiple, tags, or no search box.
.dxs--searching There is a query.
.dxs--loading A load request is in flight.
.dxs--disabled Disabled.
.dxs-option.is-active The keyboard is on this row.
.dxs-option.is-selected Chosen (also aria-selected="true").
.dxs-option.is-disabled Not choosable.
.dxs-panel--sheet Showing as a bottom sheet.

Your own markup

render hooks are handed the node to fill, so there is no HTML-string parsing and nothing to escape:

createSelect(el, {
  render: {
    option(option, node, { selected }) {
      const avatar = Object.assign(document.createElement('img'), { src: option.data.avatar, className: 'size-5 rounded-full' });
      const name = Object.assign(document.createElement('span'), { textContent: option.label });
      node.append(avatar, name);
      if (selected) node.append(Object.assign(document.createElement('span'), { textContent: '✓' }));
    },
    item: (option, node) => void (node.textContent = option.data.short ?? option.label),
    empty: (query, node) => void (node.textContent = query ? `Nothing for “${query}”` : 'Start typing'),
  },
});

Return false from a renderer to fall back to the default for that row.

Icons

createSelect(el, {
  icons: {
    chevron: '<svg …>',
    clear: '<svg …>',
    check: '<svg …>',
    remove: '<svg …>',
  },
});

Words

createSelect(el, {
  strings: {
    placeholder: 'اختر',
    searchPlaceholder: 'ابحث',
    empty: 'لا نتائج',
    create: (query) => `إضافة “${query}”`,
    counted: (count) => `${count} محدد`,
  },
});

RTL needs nothing else: the layout is written in logical properties, so a page with dir="rtl" mirrors itself, and the panel flips to the right edge of the field.

Headless

import { filterOptions, matcher, highlight, fold } from '@devix-labs/select/core';

const rows = filterOptions(options, query, { groups, pinned, hideSelected: true });

rows are the headings and options in the order they should be drawn, each with the ranges that matched — the exact data the widget's own list renders, for your components.

Updated 15 Sep 2026