The multiselect is styled entirely through CSS custom properties. Nothing about theming is specific to this Elixir wrapper — you set the same variables you would with the raw web component — but this guide frames it for a Phoenix app: where the CSS goes, and how the variables reach <.web_multiselect>.

For the exhaustive list of every variable (150+), see the upstream theming reference and the interactive KeenMate Theme Designer.

The two-tier variable cascade

Every visible color, size, and effect is exposed as a --ms-* variable, and each --ms-* variable is defined as var(--base-*, <fallback>). For example, straight from the bundled multiselect.css:

--ms-accent-color:  var(--base-accent-color, #3b82f6);
--ms-text-color-1:  var(--base-text-color-1, light-dark(#111827, #f5f5f5));
--ms-input-bg:      var(--base-input-bg, light-dark(#ffffff, #1a1a1a));
--ms-border:        var(--base-border, 1px solid var(--ms-border-color));

That gives two layers you can theme at:

LayerPrefixWho provides itEffect
Base / design tokens--base-*You, or a design system that fills them in (pure-admin)Shared across all KeenMate components at once
Component tokens--ms-*This componentAffects only the multiselect

The --base-* layer is the components' own cross-component token contract — it predates pure-admin. It was introduced first simply because a shared base layer was convenient; pure-admin came later and populates exactly these tokens, which is why adopting pure-admin themes the multiselect "for free." You can provide the --base-* values yourself (Scenario B) and get the same benefit without pure-admin.

Because --ms-* falls back to a hardcoded default (with light-dark() for automatic light/dark), the component works out of the box with no variables defined at all. Define --base-* and it inherits your design system; override --ms-* and you tweak just the multiselect.

Shadow DOM is not a barrier. The component renders into a shadow root, but CSS custom properties inherit through the shadow boundary. A --ms-accent-color set on :root, on web-multiselect, or inline on the element all reach the internals. You never need ::part() for color/size theming.

Where the CSS lives in a Phoenix app

On a standard esbuild app, put theme rules in assets/css/app.css after the component's stylesheet import:

@import "../../deps/keen_web_multiselect/priv/static/multiselect.css";

:root {
  --base-accent-color: #7c3aed;   /* affects every KeenMate component */
}

web-multiselect {
  --ms-border-radius: 0.5rem;      /* affects only multiselects */
}

Per-instance, use the forwarded class and style attributes on the component:

<.web_multiselect id="tags" class="compact" style="--ms-rem: 8px;" options={@opts} />

(class, style, and any data-* attribute pass straight through to the <web-multiselect> element — see Keenmate.WebMultiselect.Components.web_multiselect/1.)


Scenario A — with pure-admin

pure-admin populates the --base-* token contract (colors, text scale, input sizes, dark-mode surfaces) at :root. The components were already reading those tokens — every --ms-* variable is var(--base-*, …) — so when pure-admin supplies them, the multiselect themes itself to match with zero component-specific configuration — same accent, same borders, same dark palette. It worked "for free" precisely because the --base-* layer existed first and pure-admin filled it in.

Load pure-admin's stylesheet, then the component's, then you're done:

/* assets/css/app.css */
@import "pure-admin/dist/pure-admin.css";                                  /* defines --base-* */
@import "../../deps/keen_web_multiselect/priv/static/multiselect.css";     /* --ms-* read them */

One alignment knob worth setting: pure-admin scales from html { font-size: 10px }, and the component's proportional unit is --ms-rem (default 10px). Point it at rem so the two scale together:

web-multiselect { --ms-rem: 1rem; }

Dark mode comes for free. pure-admin toggles dark via a data-bs-theme="dark" (or data-theme="dark") attribute on an ancestor; the multiselect honors that same signal (see Dark mode below), so flipping pure-admin's theme flips the multiselect too. You do not wire up anything.


Scenario B — with other KeenMate components, without pure-admin

If you use several KeenMate components (multiselect, web-daterangepicker, …) but not pure-admin, define the --base-* layer yourself, once, as your single source of truth. Every component reads it:

/* assets/css/app.css — your design tokens */
:root {
  --base-accent-color:  #3b82f6;
  --base-main-bg:       light-dark(#ffffff, #1a1a1a);
  --base-hover-bg:      light-dark(#f3f4f6, #262626);
  --base-text-color-1:  light-dark(#111827, #f5f5f5);
  --base-border-color:  light-dark(#e5e7eb, #3a3a3a);
}

Change --base-accent-color once and the multiselect (--ms-accent-color), the daterangepicker (--drp-accent-color), and every other component update together. The Tier-1 names are aligned across components so the mapping is predictable:

PurposeBase tokenmultiselectdaterangepicker
Brand / accent--base-accent-color--ms-accent-color--drp-accent-color
Background--base-main-bg--ms-primary-bg--drp-primary-bg
Text--base-text-color-1--ms-text-color-1--drp-text-primary
Border--base-border-color--ms-border-color--drp-border-color

This is the base-layer contract in its original form — a shared token set you own. pure-admin is simply one ready-made provider of it; Scenario B is "bring your own base layer" and predates the pure-admin option.


Scenario C — standalone / just the multiselect

No --base-* tokens, no other components. The component's built-in fallbacks — each with light-dark() — already give a working light and dark theme, so you can ship with nothing at all.

To restyle only the multiselect, override --ms-* directly. Scope it to the element selector so it can't leak into other components:

web-multiselect {
  --ms-accent-color:        #10b981;   /* emerald */
  --ms-input-border-radius: 0.5rem;
  --ms-dropdown-max-height: 24rem;
}

…or to a single instance from HEEx:

<.web_multiselect
  id="languages"
  style="--ms-accent-color:#e11d48; --ms-badge-border-radius:9999px;"
  options={@languages}
/>

Because these are --ms-* (not --base-*), nothing else on the page is affected.


Sizing

--ms-rem is the proportional base unit (default 10px); most sizes are calc(N * var(--ms-rem)). Scale the whole component with one variable:

web-multiselect.compact { --ms-rem: 8px;  }   /*  80% */
web-multiselect         { --ms-rem: 10px; }   /* 100% (default) */
web-multiselect.large   { --ms-rem: 12px; }   /* 120% */
<.web_multiselect id="c" class="compact" options={@opts} />

Five input-height variants read --base-input-size-*-height, so setting them at the base layer keeps input heights consistent across all KeenMate components:

:root { --base-input-size-md-height: 4.0; }   /* 40px at --ms-rem: 10px */

Option rows — height and long labels

Dropdown option rows (including tree nodes) are content-driven by default: a long title wraps and the row grows. Two option-level variables change that:

VariableDefaultEffect
--ms-option-min-heightautoA consistent minimum row height. Rows still grow if content is taller. (Virtual-scroll rows use the fixed --ms-option-height instead.)
--ms-option-title-white-spacenormalSet nowrap to keep the title on one line.
--ms-option-title-overflowvisibleSet hidden to clip the overflow.
--ms-option-title-text-overflowclipSet ellipsis for a trailing .
web-multiselect {
  --ms-option-min-height: 3.5rem;

  /* truncate long titles to one line + ellipsis */
  --ms-option-title-white-space: nowrap;
  --ms-option-title-overflow: hidden;
  --ms-option-title-text-overflow: ellipsis;
}

.ms__option-title is the label hook (the analog of web-treeview's .wtv__node-label). When truncating, turn on enable_option_tooltips={true} so the full label appears on hover. See the Tree of options guide for the tree angle.

Dark mode

Since upstream v1.12.0 the component honors five dark-mode signals — pick whichever your app already uses; you don't wire up all of them:

SignalSet byExample
OS preference + page color-schemeYouhtml { color-scheme: light dark }
Page color-scheme: darkYoubody { color-scheme: dark }
Framework data-* on an ancestorBootstrap, pure-admin<html data-bs-theme="dark"> / <div data-theme="dark">
Framework class on an ancestorTailwind, custom<html class="dark">
Per-instance attribute on the hostYou<.web_multiselect data-theme="dark" … />

Precedence (highest wins): per-instance → framework ancestor → page color-scheme. To force one widget light on an otherwise-dark page, set data-theme="light" on it:

<.web_multiselect id="always-light" data-theme="light" options={@opts} />

The unlayered-reset footgun

The component's internal CSS uses named cascade layers (@layer variables, component, overrides;). A global reset like * { margin: 0; padding: 0 } (Bootstrap reboot, Tailwind preflight) is unlayered, so it beats every layered rule inside the component and can break its spacing even though your variables resolved correctly. Wrap your reset in its own layer so the component's defaults can win:

@layer reset { * { margin: 0; padding: 0; box-sizing: border-box; } }

Variables-first theming (--ms-* / --base-*) covers ~95% of customization; cascade layers are the escape hatch for the rest.