Composition
Snippets, the as prop, and detached handles.
Each part keeps its behavior — ARIA, keyboard, focus, and data-* state — and leaves the rest to you: the tag it renders, its attributes, and its content.
Nesting is the API
Parts talk to each other through Svelte context, not props. A <Menu.Root> publishes its state to context; <Menu.Trigger>, <Menu.Positioner>, and <Menu.Item> read it back. That's why you nest them instead of wiring open/onOpenChange between siblings by hand. The shared state never appears in your markup at all.
<script>
import { Menu } from '@shardsui/svelte/menu'
</script>
<Menu.Root>
<Menu.Trigger>Song</Menu.Trigger>
<Menu.Portal>
<Menu.Positioner>
<Menu.Popup>
<Menu.Item>Add to Library</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>The only rule this imposes: a part must be a descendant of its Root. Depth and intervening markup don't matter, so wrap parts in your own layout elements as you like. When the nesting genuinely can't hold (a trigger that lives in a header, a popup that lives in a route), reach for a handle instead.
Content is a snippet
Whatever you put between a part's tags is its children snippet, rendered wherever the part decides its content belongs:
<Switch.Root bind:checked>
<Switch.Thumb>
<Icon name="check" />
</Switch.Thumb>
</Switch.Root>Most parts hand their own state back through that snippet. Write it in the named form to receive it and branch on it in markup:
<script>
import { Switch } from '@shardsui/svelte/switch'
</script>
<Switch.Root>
<Switch.Thumb>
{#snippet children({ checked })}
{#if checked}
<CheckedIcon />
{:else}
<UncheckedIcon />
{/if}
{/snippet}
</Switch.Thumb>
</Switch.Root>The named form is otherwise identical to markup between the tags. It lets you name the parameters. What a part passes is listed in its children row in the API reference. The same values are also emitted as data-* attributes, so reach for the snippet when you need different markup; for styling alone, data-* in CSS is simpler.
<Dialog.Root> passes something other than its own state: the payload of whichever trigger opened it.
<script lang="ts">
import { Dialog } from '@shardsui/svelte/dialog'
const confirm = new Dialog.Handle<{ name: string }>()
</script>
<Dialog.Trigger handle={confirm} payload={{ name: 'billing' }}>Delete</Dialog.Trigger>
<Dialog.Root handle={confirm}>
{#snippet children({ payload })}
<Dialog.Portal>
<Dialog.Popup>
<Dialog.Title>Delete {payload?.name}?</Dialog.Title>
</Dialog.Popup>
</Dialog.Portal>
{/snippet}
</Dialog.Root>Changing the rendered element
Every part picks the element that's correct for its role, and the as prop swaps it. <Menu.Item> renders a <div> by default; render it as an <a> and it behaves like a real link while keeping its menu-item semantics:
<Menu.Item as="a" href="https://example.com">Add to Library</Menu.Item>as takes an HTML tag name — 'a', 'button', 'span', and so on — not a component. Reach for it only when a case like the anchor above calls for a different tag.
In SvelteKit a plain <a href> routes on the client, so an anchor like that one navigates with no link wrapper around it. <NavigationMenu.Link> renders an <a> to begin with:
<NavigationMenu.Link href="/pricing">Pricing</NavigationMenu.Link>Merging your own attributes
Any attribute that isn't one of a part's own props is forwarded straight to the element it renders. In the link above, href isn't a Menu.Item prop, so it lands on the <a>. The same path carries event handlers, ARIA, data-*, and styling hooks:
<Menu.Item data-section="billing" aria-label="Open billing" onclick={() => console.log('selected')}>
Billing
</Menu.Item>A handler doesn't replace the part's: for every event a part handles, yours runs first and the part's runs after, so its ARIA, keyboard handling, and data-* state all survive.
When you need the part to stand down for one event, call preventShardsUIHandler() on it, an escape hatch for cases no prop covers yet:
<Menu.Item
onclick={(event) => {
if (!ready) event.preventShardsUIHandler()
}}
>
Billing
</Menu.Item>Getting the DOM node
Bind the underlying element with bind:ref. It's populated after the element mounts, so read it from an effect or an event handler, never during render:
<script lang="ts">
import { Popover } from '@shardsui/svelte/popover'
let triggerEl = $state<HTMLElement | null>(null)
$effect(() => {
if (triggerEl) console.log(triggerEl.getBoundingClientRect())
})
</script>
<Popover.Root>
<Popover.Trigger bind:ref={triggerEl}>Open</Popover.Trigger>
...
</Popover.Root>Every part that renders its own element exposes ref. Pure providers like Dialog.Root don't.
Wrapping a part in your own component
There's no render prop: to build a styled component out of a part, render the part and spread the rest of your props onto it. Destructure ref first if callers should be able to bind it. A spread passes values, not bindings.
<script lang="ts">
import type { ComponentProps } from 'svelte'
import { Menu } from '@shardsui/svelte/menu'
let { ref = $bindable(null), ...rest }: ComponentProps<typeof Menu.Item> = $props()
</script>
<Menu.Item as="a" class="menu-link" bind:ref {...rest} />class sits before the spread, so a caller's own class wins. See TypeScript for more on typing a wrapper.
Detaching parts with a handle
When a trigger and the thing it opens can't sit together in the markup — a toolbar button and a dialog defined in a different route, say — a handle connects them without any shared open state threaded through props. Create one with the component's Handle class, pass it to both ends, and they find each other:
<script>
import { Dialog } from '@shardsui/svelte/dialog'
const settings = new Dialog.Handle()
</script>
<Dialog.Trigger handle={settings}>Open settings</Dialog.Trigger>
<!-- ...anywhere else in the tree... -->
<Dialog.Root handle={settings}>...</Dialog.Root>A handle also drives the component from your own code, no trigger required: settings.open(triggerId), settings.close(), and a readonly settings.isOpen. open takes the id of a registered detached trigger so the popup knows what it was opened from. Popover, Menu, Tooltip, and Preview Card throw if no trigger with that id is registered, while Dialog, Alert Dialog, and Drawer also accept null for "no trigger" and add openWithPayload(payload). new Dialog.Handle<Payload>() types the payload that flows through the trigger and into the root's children snippet. See TypeScript for the details.
Handles are available on the overlay components where a detached trigger makes sense: Dialog, AlertDialog, Drawer, Popover, Menu, Tooltip, and PreviewCard.
Providers
A provider renders no element of its own — it only publishes context to everything nested inside it. DirectionProvider is the one that applies to every component: wrap a subtree, or the whole app, and the components inside navigate and lay out for right-to-left reading.
<script>
import { DirectionProvider } from '@shardsui/svelte/direction-provider'
</script>
<DirectionProvider direction="rtl">
<!-- components here read RTL -->
</DirectionProvider>It changes component behavior only, never the DOM's own text direction. See Reading direction for what that leaves you to do.
Three components ship a provider of their own: <Tooltip.Provider> shares one delay across the tooltips inside it, so the next one opens instantly; <Toast.Provider> holds the toast queue; and <Drawer.Provider> tracks which drawers are open, driving <Drawer.Indent> and <Drawer.IndentBackground>.