Skip to content

Chapter 48: Disclosure, Select, and Native Controls

Not every control needs to be invented again.

The platform has a steadily growing library of native interactive controls. <details> and <summary> for disclosure widgets. <select> for choice fields. <dialog> for modals (covered in Chapter 47). The Popover API for non-modal overlays. The new customizable <select> for fully-styled choice fields. Each of these is the platform’s answer for a category of UI that frontend has been rebuilding repeatedly for years.

This chapter is about using these natives well. kit-disclosure wraps <details>. kit-select wraps <select> (including the modern customizable variant when it’s available). kit-popover uses the Popover API. The pattern is the same as kit-button and kit-dialog — native primitive inside, Lit for authoring, accessibility preserved, metadata participation by default.

The chapter is shorter than the previous two because the work is largely the same shape. The point worth landing is that the platform’s interactive controls are a substantial library of well-engineered work that most teams routinely bypass. Using them is a small architectural lift that pays off in accessibility, maintenance, and bundle size.

The platform’s disclosure widget has been around since HTML5.

<details>
<summary>Advanced settings</summary>
<p>The settings appear here when the disclosure is open.</p>
</details>

What this gives you, free:

  • Click on the summary to toggle open/closed.
  • Keyboard activation (Enter and Space on the summary).
  • An open attribute reflected on <details> that CSS can target.
  • A toggle event fired when the state changes.
  • Accessibility-tree role of group with the summary as its accessible name.
  • Screen-reader announcement of the state.
  • Form participation (the disclosure’s open state can be styled with :has(details[open]) selectors).

For most disclosure use cases, this is sufficient. A kit-disclosure component wraps it for design-system consistency:

class KitDisclosure extends LitElement {
@property({ type: Boolean, reflect: true }) open = false
@property({ type: String }) label = ''
static styles = css`
details {
border: 1px solid var(--kit-border, rgba(0,0,0,0.1));
border-radius: 4px;
overflow: hidden;
}
summary {
padding: 0.75rem 1rem;
cursor: pointer;
list-style: none;
display: flex;
align-items: center;
justify-content: space-between;
background: var(--kit-surface-subtle, transparent);
}
summary::-webkit-details-marker { display: none; }
summary::marker { content: ''; }
summary::after {
content: '▸';
transition: transform 200ms ease;
}
details[open] summary::after {
transform: rotate(90deg);
}
.body {
padding: 1rem;
}
@media (prefers-reduced-motion: reduce) {
summary::after { transition: none; }
}
`
render() {
return html`
<details ?open=${this.open} @toggle=${this.handleToggle}>
<summary>
<slot name="label">${this.label}</slot>
</summary>
<div class="body">
<slot></slot>
</div>
</details>
`
}
private handleToggle(event: Event) {
const details = event.target as HTMLDetailsElement
this.open = details.open
this.dispatchEvent(new CustomEvent(this.open ? 'kit-disclosure:opened' : 'kit-disclosure:closed', {
bubbles: true,
composed: true
}))
}
}
customElements.define('kit-disclosure', KitDisclosure)

The component is around 50 lines. The actual work it adds on top of <details> is the styling (the rotate-arrow marker), the named label slot, and the composed events. The platform handles everything else.

A common pattern: groups of disclosures that should behave like an accordion (only one open at a time). The platform’s <details name="..."> attribute (shipping in 2024–2025) handles this:

<kit-disclosure name="accordion-group" label="Section 1">Content 1</kit-disclosure>
<kit-disclosure name="accordion-group" label="Section 2">Content 2</kit-disclosure>
<kit-disclosure name="accordion-group" label="Section 3">Content 3</kit-disclosure>

When name is the same on multiple <details> elements, opening one automatically closes the others. The component just forwards the attribute; the platform handles the coordination.

Native <select> has, historically, been one of the most-rebuilt platform controls. The reasons are real: styling has been limited (you couldn’t make the dropdown match the design system), the option list couldn’t include rich content, and the popover behavior wasn’t customizable.

The replacements have been expensive. Custom comboboxes need to handle keyboard navigation (arrow keys, type-to-search, Home/End, page navigation), focus management, the listbox role pattern, screen-reader announcements, form integration, mobile touch behavior, and the visual styling of the dropdown. Most implementations get some of this wrong. The libraries that get it right (Downshift, Headless UI’s combobox, React-Aria’s combobox) earn their place by being thorough.

The platform has been catching up. The work has two pieces.

The Popover API (shipping in 2023–2024) gives custom dropdowns a real top-layer rendering target — the dropdown can appear above other content without z-index games or stacking-context issues. The API is small and broadly applicable.

The customizable <select> (Open UI Working Group, shipping in 2024–2025) makes native <select> fully styleable. The element’s parts (the trigger, the dropdown, the option list, the individual options) can be styled with CSS. The platform handles every other behavior. The element is a real <select> with the platform’s full select-control semantics; it just looks however the design system wants.

A kit-select that uses the customizable select when available, and falls back gracefully:

class KitSelect extends LitElement {
static formAssociated = true
@property({ type: String }) name = ''
@property({ type: String }) value = ''
@property({ type: Boolean, reflect: true }) disabled = false
private internals: ElementInternals
constructor() {
super()
this.internals = this.attachInternals()
}
static styles = css`
:host { display: inline-block; }
select {
font: inherit;
padding: 0.5em 1em;
padding-right: 2em;
border-radius: 4px;
border: 1px solid var(--kit-border, currentColor);
background: var(--kit-surface, transparent);
color: inherit;
/* Use the customizable select when supported */
appearance: base-select;
}
select:focus-visible {
outline: 2px solid var(--kit-color-focus, currentColor);
outline-offset: 2px;
}
select::picker(select) {
background: var(--kit-surface, white);
border: 1px solid var(--kit-border, rgba(0,0,0,0.1));
border-radius: 4px;
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
}
select option {
padding: 0.5em 1em;
}
select option:checked {
background: var(--kit-color-accent, #4a90e2);
color: white;
}
`
render() {
return html`
<select
?disabled=${this.disabled}
@change=${this.handleChange}
.value=${this.value}
>
<slot></slot>
</select>
`
}
private handleChange(event: Event) {
const select = event.target as HTMLSelectElement
this.value = select.value
this.internals.setFormValue(this.value)
this.dispatchEvent(new CustomEvent('kit-select:changed', {
bubbles: true,
composed: true,
detail: { value: this.value }
}))
}
}
customElements.define('kit-select', KitSelect)

Usage:

<kit-select name="theme">
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="system">System</option>
</kit-select>

The component is small. The native <select> handles keyboard navigation, the dropdown popover, the mobile-native picker, form integration. The component forwards the value through internals.setFormValue so the select participates in form data. The CSS uses appearance: base-select and ::picker(select) to style the customizable select on browsers that support it; older browsers fall back to the legacy native styling.

For applications that need richer option content (icons, descriptions, custom rendering per option), the customizable select supports rich content inside <option> elements (paragraphs, images, structured content). The capability that previously required a custom combobox is now native.

For comboboxes with type-to-search filtering (the user types and the list filters as they go), the platform doesn’t yet have a native answer, and a custom implementation is justified. Even then, the implementation can use the Popover API for the dropdown and the customizable select machinery for the option list, reducing the amount of code that has to be hand-built.

The Popover API (shipping in 2023–2024) gives the platform a primitive for non-modal overlay positioning.

<button popovertarget="help-popover">Help</button>
<div id="help-popover" popover>
<h3>Help</h3>
<p>Helpful information appears here.</p>
</div>

The attribute popover on the target element makes it a popover. The attribute popovertarget on the trigger creates the relationship. Clicking the trigger toggles the popover. Clicking outside the popover or pressing Escape closes it. The popover renders in the top layer, so it isn’t cut off by overflow: hidden. Screen readers announce it appropriately.

For most non-modal overlay use cases — tooltips, menus, autocomplete dropdowns, hover cards — this is sufficient. A kit-popover component wraps it for design-system consistency:

class KitPopover extends LitElement {
@property({ type: String, reflect: true }) anchor = ''
@property({ type: String, reflect: true }) placement: 'top' | 'bottom' | 'left' | 'right' = 'bottom'
static styles = css`
:host {
display: contents;
}
::slotted([popover]) {
padding: 0.75rem 1rem;
background: var(--kit-surface, white);
border: 1px solid var(--kit-border, rgba(0,0,0,0.1));
border-radius: 4px;
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
max-width: 24rem;
}
`
render() {
return html`<slot></slot>`
}
}
customElements.define('kit-popover', KitPopover)

The component is barely a component. The popover behavior comes from the platform. The component adds visual styling and the convenience of a wrapper that an application can attach metadata to.

The Popover API also supports anchor positioning (CSS position-area and anchor-name) for relative placement against a trigger element. The full anchor-positioning capability is still rolling out across browsers but is increasingly viable for production use in 2025.

The chapter has been arguing for native primitives. The honest position is that sometimes custom is justified, and the architecture should accommodate it.

A combobox with type-to-search filtering, async loading of options, multi-selection, virtual scrolling for thousands of options, and free-form input — this isn’t natively available. Building it custom is a real project. Libraries like Downshift, Headless UI, React-Aria, and the Open UI Combobox proposal are the right tools for that kind of work.

A rich-text editor with formatting, mentions, embeddings, and collaboration — definitely custom. The platform’s contenteditable is a starting point, but serious rich-text editing (Slate, Tiptap, ProseMirror, Lexical) is its own engineering domain.

A complex date-range picker with two calendars, range highlighting, custom date semantics — usually custom, though the platform’s <input type="date"> and <input type="month"> plus a small layout helper can handle many simpler date needs.

For everything else — buttons, dialogs, basic disclosures, basic selects, popovers, simple form fields, navigation, modals — the platform is the right starting point. The rule is use the native primitive unless the product specifically requires a capability the platform doesn’t have. The product requirement should be specific and named, not the native one doesn’t look right. Styling can almost always be solved through CSS.

The platform-first discipline doesn’t reject custom controls. It just makes the choice deliberate.

The Kit components in this chapter are deliberately small. They don’t try to be a complete component library. They don’t try to do what Radix, Headless UI, or React-Aria do. They’re examples of how to wrap native primitives in the architecture, not a replacement for those libraries’ more sophisticated work.

For applications that need a richer component library, the Kit approach extends naturally. Build the components your application needs, wrapping native primitives when possible, using Lit for authoring, participating in the metadata protocol. The architecture supports an arbitrarily large component library; this chapter just builds a few representative examples.

For applications that don’t need to build their own components, using an existing component library (Lit’s official lion, Adobe’s Spectrum Web Components, IBM’s Carbon for Web Components, the Open UI implementations) inside the architecture works too. Any custom-element-based library composes with the runtime, the metadata boundary, and the rest of the architecture.

The next chapter (Chapter 49) covers the more elaborate cousins of buttons — form-associated custom elements. The pattern from Chapter 24 (forms as transactions) gets implemented in the component library. <kit-input>, <kit-select> (with form participation), <kit-textarea>, <kit-checkbox>, <kit-radio-group> — each one participates in FormData, contributes to constraint validation, and integrates with the platform’s form-submission machinery.

After Chapter 49, the Kit components can build complete forms. Chapter 50 covers styling. Chapter 51 ties everything together with design systems. Then Part VI ships the maintained version as Kitsune.

Exercise: Build kit-disclosure and kit-select

Section titled “Exercise: Build kit-disclosure and kit-select”

Implement kit-disclosure and kit-select from the patterns in this chapter.

For kit-disclosure:

  1. Wrap native <details> and <summary>.
  2. Add CSS that styles the marker with a rotating chevron.
  3. Add a label slot and a default content slot.
  4. Forward the name attribute to support the accordion-group pattern.
  5. Dispatch composed kit-disclosure:opened and kit-disclosure:closed events.

Then test:

  1. Keyboard: Tab to the summary, press Enter, verify the disclosure toggles.
  2. Click on the summary, verify it toggles.
  3. Group multiple disclosures with the same name; verify only one is open at a time.
  4. Inspect with VoiceOver/NVDA; verify the disclosure is announced correctly.

For kit-select:

  1. Wrap native <select> with form association via ElementInternals.
  2. Add CSS that uses appearance: base-select and ::picker(select) for the customizable-select styling.
  3. Forward the value to the form via setFormValue.
  4. Dispatch composed kit-select:changed events.

Then test:

  1. Use the select in a form. Verify its value participates in FormData.
  2. Keyboard: Tab to the select, press Space or Enter to open, arrow keys to navigate, Enter to select.
  3. Try the select on mobile. Verify the native picker appears.
  4. Try the select in a browser that supports appearance: base-select. Verify the customizable styling applies.
  5. Try in a browser that doesn’t yet. Verify the legacy native styling still works.

Then think about the architecture:

  1. What happens if you add meta-event attributes to these components? (The metadata boundary observes their composed events; the runtime fires the application-level event.)
  2. How would you build a combobox with type-to-search filtering on top of the customizable select?
  3. When is the platform’s native select the right answer? When does a custom combobox earn its place?

The components in this chapter are the simplest cases. The pattern they establish — native primitive inside, thin Lit decoration, metadata participation by default — is what Part V’s remaining chapters extend. By the end of Part V, the Kit library will have enough components to build real applications.