ShardsUI is in beta. APIs may change before 1.0.

Styling

Style parts with CSS or Tailwind.

Every part renders a real HTML element with no styles of its own. You write the CSS; the library exposes its state as data-* attributes and CSS variables you can target.

The class and style props

Every part that renders an element takes a class prop, applied to that element. Svelte's string, array and object forms all work:

<Switch.Thumb class="switch-thumb" />

Prefer a state selector over a class you compute yourself — most on/off state is already a data-* attribute (below).

Parts take a style prop too, for the values only the running app knows:

<Switch.Thumb style="transform: translateX({x}px)" />

Parts are components, and Svelte's class: and style: directives only apply to elements, so build the string yourself rather than reaching for style:transform.

State as data-* attributes

Each part mirrors its state onto the element as data-* attributes. Write one static class and style against the attribute.

Switch publishes [data-checked] and [data-unchecked]:

.switch-thumb {
  transform: translateX(0);
  transition: transform 150ms;
}

.switch-thumb[data-checked] {
  transform: translateX(1rem);
}

The attributes are consistent across the library:

  • On/off state[data-open] / [data-closed], [data-checked] / [data-unchecked], [data-disabled], [data-selected], and [data-highlighted] for the item the keyboard or pointer has highlighted in a list.
  • Resolved position[data-side] and [data-align] on a positioner and popup carry where a floating element landed after collision handling, so a popup can style the edge nearest its trigger.
  • Enter and exit[data-starting-style] marks an element the frame it mounts; [data-ending-style] the moment before it unmounts. Set resting styles as the default and transitional styles behind these two attributes, and mount/unmount animate with a plain transition. See Animation.

Props & attributes lists the shared set; each component's API reference lists what its own parts expose.

CSS variables: live measurements

Where an attribute can't carry a number, a part sets a CSS variable for sizing or transform math. It updates as the layout changes.

Accordion measures its panel and exposes --accordion-panel-height, which animates a height from auto:

.accordion-panel {
  overflow: hidden;
  height: var(--accordion-panel-height);
  transition: height 200ms;
}

.accordion-panel[data-starting-style],
.accordion-panel[data-ending-style] {
  height: 0;
}

Floating parts publish measurements the same way. A Select or Popover positioner sets --anchor-width / --anchor-height (the trigger's size), --available-width / --available-height (room before the viewport edge), and --transform-origin (the point nearest the anchor). They sit on the positioner and inherit down, so the popup can read them:

.select-popup {
  min-width: var(--anchor-width);
  max-height: var(--available-height);
  transform-origin: var(--transform-origin);
}

Every Dialog popup sets --nested-dialogs — how many dialogs are open inside it — paired with [data-nested-dialog-open], so a parent can shrink or dim as children stack on top.

Tailwind

Pass utilities through each part's class prop, and key state variants off the same data-* attributes with Tailwind's data-* syntax (data-highlighted:…, data-starting-style:…). Read a CSS variable with the (--var) shorthand, as in origin-(--transform-origin):

<script>
  import { Menu } from '@shardsui/svelte/menu'
</script>

<Menu.Root>
  <Menu.Trigger
    class="flex h-8 items-center justify-center rounded-md border border-gray-950 bg-white px-3 text-sm text-gray-950 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-200 data-popup-open:bg-gray-100"
  >
    Song
  </Menu.Trigger>
  <Menu.Portal>
    <Menu.Positioner class="outline-hidden" sideOffset={8}>
      <Menu.Popup
        class="origin-(--transform-origin) border border-gray-950 bg-white py-1 text-gray-950 outline-hidden transition data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0"
      >
        <Menu.Item
          class="flex py-2 pr-8 pl-4 text-sm/4 outline-hidden select-none data-highlighted:bg-gray-950 data-highlighted:text-white"
        >
          Add to Library
        </Menu.Item>
        <Menu.Item
          class="flex py-2 pr-8 pl-4 text-sm/4 outline-hidden select-none data-highlighted:bg-gray-950 data-highlighted:text-white"
        >
          Add to Playlist
        </Menu.Item>
      </Menu.Popup>
    </Menu.Positioner>
  </Menu.Portal>
</Menu.Root>

Plain CSS

Give each part a class, then write the rules in a stylesheet. The same data-* attributes and CSS variables work from these selectors (.menu-item[data-highlighted], .menu-popup[data-starting-style]):

<script>
  import './menu.css'
  import { Menu } from '@shardsui/svelte/menu'
</script>

<Menu.Root>
  <Menu.Trigger class="menu-trigger">Song</Menu.Trigger>
  <Menu.Portal>
    <Menu.Positioner class="menu-positioner" sideOffset={8}>
      <Menu.Popup class="menu-popup">
        <Menu.Item class="menu-item">Add to Library</Menu.Item>
        <Menu.Item class="menu-item">Add to Playlist</Menu.Item>
      </Menu.Popup>
    </Menu.Positioner>
  </Menu.Portal>
</Menu.Root>
.menu-trigger {
  display: flex;
  height: 2rem;
  align-items: center;
  border: 1px solid var(--color-gray-950);
  background-color: white;
  padding-inline: 0.75rem;
}

.menu-popup {
  transform-origin: var(--transform-origin);
  border: 1px solid var(--color-gray-950);
  background-color: white;
  padding-block: 0.25rem;
}

.menu-item {
  display: flex;
  padding: 0.5rem 2rem 0.5rem 1rem;
  user-select: none;
}

.menu-item[data-highlighted] {
  background-color: var(--color-gray-950);
  color: white;
}

Scoped <style> reaches your markup, not the parts

Scoped <style> stamps a hash onto the elements you write directly in a component and adds that hash to every selector. A class you pass to a part lands on the part's own element, deeper in the library — that element never gets your hash, so a scoped selector never matches it. This holds for every part, portalled or not.

So this rule does nothing:

<style>
  /* Never matches — the Popup isn't scoped to this component. */
  .menu-popup {
    border: 1px solid var(--color-gray-950);
  }
</style>

Two ways through:

  1. Put the rules in a stylesheet (above).
  2. Opt the class out of scoping with :global. Alone, :global(.menu-popup) matches that class anywhere on the page. Anchor it to a wrapper you author and Svelte hashes the leading selector, so only what's inside :global stays global:
<div class="faq">
  <Accordion.Root>
    <Accordion.Item>
      <Accordion.Header>
        <Accordion.Trigger>Shipping</Accordion.Trigger>
      </Accordion.Header>
      <Accordion.Panel class="accordion-panel">Ships in 2–3 days.</Accordion.Panel>
    </Accordion.Item>
  </Accordion.Root>
</div>

<style>
  /* `.faq` is scoped to this component, so the rule only reaches
     panels rendered inside it. */
  .faq :global(.accordion-panel) {
    overflow: hidden;
  }
</style>

Styling several parts under one wrapper, the block form keeps the globals together: .faq :global { .accordion-panel { … } .accordion-trigger { … } }.

This reaches only parts that stay in your markup. A portalled part (dialog, tooltip, menu, …) is moved out of the wrapper, so an ancestor selector no longer matches it; use a bare :global(.menu-popup) or a stylesheet for those.