We build a data grid, and its cell editors (combo box, date picker, number input...) also had to work as standalone components. People kept asking to restyle them without forking. So we split every component into two parts.
The core is a plain function. It takes getters for its inputs and returns state plus "prop-getters" you spread onto your own elements:
<script lang="ts">
import { createListbox } from '@svgrid/grid'
let value = $state(null)
const options = [{ value: 'a', label: 'Apple' }, { value: 'b', label: 'Banana' }]
const lb = createListbox({
options: () => options,
value: () => value,
onChange: (v) => (value = v),
})
</script>
<ul {...lb.rootProps()}>
{#each options as opt, i (opt.value)}
<li {...lb.optionProps(i)}>{opt.label}</li>
{/each}
</ul>
rootProps() and optionProps(i) return the role, the aria-* attributes, tabindex and the key/click handlers. Your markup gets the full listbox keyboard behavior without any of our CSS.
How we drew the line:
- The core owns state, keyboard handling, ARIA, and parsing/formatting (clamping and stepping for the number input, filtering for the combo box). It never touches the DOM.
- The styled component owns the CSS, positioning for popups, scroll-into-view and DOM measurement.
Inputs are getters (value: () => value), not values, because passing a $state value into a function in Svelte 5 reads it once at that moment. A getter keeps the core reactive without stores.
One snag: dropdown panels portal to <body> so the grid's scroll container can't clip them. That breaks CSS variable inheritance, so the theme's --sg-* tokens have to be copied onto the panel.
RTL also belongs in the core. With dir="rtl", the arrow keys reverse in tabs, sliders and trees, on top of the layout flipping.
Source (MIT): https://github.com/sv-grid/sv-grid
If you've built headless components in React or Vue, did you use prop-getters, or render props/slots? We're still not sure prop-getters scale well to the more complex widgets.