Customize
Styling and theming
Every visual decision can be changed, at whichever level suits the project:
- CSS variables. Colours, sizes, radii and weights — a few lines per brand.
classNames. Your own classes on every part and state, for Tailwind, Bootstrap or utility CSS.- Plain CSS. Stable
dxs-*classes,is-*state classes and ARIA attributes. renderandicons. Your own markup for a row, a chip, a heading or the empty state.- Your own UI.
@devix-labs/select/coregives 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.