Svelte Headless UI

General concepts

Component styling

Out of the box, these components are completely unstyled. This has some big advantages: you’re free to make them look however you want, without having to fight against any pre-existing styles that don’t fit in with the look and feel of your site. Instead of being constrained by an existing design system like Material Design or Carbon Components, you can fully customize your components’ appearance—but letting the library take care of implementing all the keyboard and mouse interactions, accessibility features, and so on.

The flipside of unstyled components, however, is that you need to, well, style them. There are a number of ways of doing this.

Styling children: slot props

You can style the descendants of components from this library with the help of slot props:

<script>
  import {
    RadioGroup,
    RadioGroupLabel,
    RadioGroupOption,
    RadioGroupDescription,
  } from "@rgossiaux/svelte-headlessui";
  let plan = "startup";
</script>

<RadioGroup value={plan} on:change={(e) => (plan = e.detail)}>
  <RadioGroupLabel>Plan</RadioGroupLabel>
  <RadioGroupOption value="startup" let:checked>
    <span class:checked>Startup</span>
  </RadioGroupOption>
  <RadioGroupOption value="business" let:checked>
    <span class:checked>Business</span>
  </RadioGroupOption>
  <RadioGroupOption value="enterprise" let:checked>
    <span class:checked>Enterprise</span>
  </RadioGroupOption>
</RadioGroup>

<style>
  /* Note that using global styles this way is bad practice in larger applications; see below for more */
  :global(.checked) {
    background-color: rgb(191 219 254);
  }
</style>

Styling the components themselves: class and style

Slot props can be used to style any descendent components, but they cannot be used to style the same component that defines them. To work around this, any component that defines slot props will also optionally accept a function for its class and/or style props. These functions will be called with the slot props as an argument:

<script>
  import {
    RadioGroup,
    RadioGroupLabel,
    RadioGroupOption,
    RadioGroupDescription,
  } from "@rgossiaux/svelte-headlessui";
  let plan = "startup";
</script>

<RadioGroup value={plan} on:change={(e) => (plan = e.detail)}>
  <RadioGroupLabel>Plan</RadioGroupLabel>
  <RadioGroupOption
    value="startup"
    class={({ checked }) => (checked ? "checked" : undefined)}
  >
    <span class:checked>Startup</span>
  </RadioGroupOption>
  <RadioGroupOption
    value="business"
    class={({ checked }) => (checked ? "checked" : undefined)}
  >
    <span class:checked>Business</span>
  </RadioGroupOption>
  <RadioGroupOption
    value="enterprise"
    class={({ checked }) => (checked ? "checked" : undefined)}
  >
    <span class:checked>Enterprise</span>
  </RadioGroupOption>
</RadioGroup>

<style>
  /* Note that using global styles this way is bad practice in larger applications; see below for more */
  :global(.checked) {
    background-color: rgb(191 219 254);
  }
</style>

See the individual component documentation to learn about the slot props for each component.

You may also pass a string for class or style, or omit them entirely, of course.

What follows below is some guidance on the different ways to use the class and style props to style your components.

Using Svelte’s <style> tags

Svelte comes with an out-of-the-box solution for component styling: using the <style> tag inside your components. This generates CSS that is scoped to an individual component instance, letting you do things like this without worrying about your CSS selectors conflicting with other components:

<script>
  import {
    RadioGroup,
    RadioGroupLabel,
    RadioGroupOption,
    RadioGroupDescription,
  } from "@rgossiaux/svelte-headlessui";
  let plan = "startup";
</script>

<RadioGroup value={plan} on:change={(e) => (plan = e.detail)}>
  <RadioGroupLabel>Plan</RadioGroupLabel>
  <RadioGroupOption value="startup" let:checked>
    <span class:checked>Startup</span>
  </RadioGroupOption>
  <RadioGroupOption value="business" let:checked>
    <span class:checked>Business</span>
  </RadioGroupOption>
  <RadioGroupOption value="enterprise" let:checked>
    <span class:checked>Enterprise</span>
  </RadioGroupOption>
</RadioGroup>

<style>
  .checked {
    background-color: rgb(191 219 254);
  }
</style>

This works great for styling HTML elements. However, it (unfortunately) does not work for styling components. If you try to create the same example without the <span>s, it won’t work:

<script>
  import {
    RadioGroup,
    RadioGroupLabel,
    RadioGroupOption,
    RadioGroupDescription,
  } from "@rgossiaux/svelte-headlessui";
  let plan = "startup";
</script>

<RadioGroup value={plan} on:change={(e) => (plan = e.detail)}>
  <RadioGroupLabel>Plan</RadioGroupLabel>
  <RadioGroupOption
    value="startup"
    class={({ checked }) => (checked ? "checked" : "")}
  >
    Startup
  </RadioGroupOption>
  <RadioGroupOption
    value="business"
    class={({ checked }) => (checked ? "checked" : "")}
  >
    Business
  </RadioGroupOption>
  <RadioGroupOption
    value="enterprise"
    class={({ checked }) => (checked ? "checked" : "")}
  >
    Enterprise
  </RadioGroupOption>
</RadioGroup>

<!-- This doesn't work! -->
<style>
  .checked {
    background-color: rgb(191 219 254);
  }
</style>

(Note that we also can no longer use the class:checked shorthand on a component)

Why doesn’t this work? When Svelte compiles our component, it adds in a special unique class to the CSS rules and to the HTML elements rendered by this component that are affected. But it does not add this special class to any child components. The result is that the .checked CSS rule will be generated with a second class that the <RadioGroupOption> components do not have, and it will not match.

Unfortunately there is no way in Svelte right now to pass this special class down to the <RadioGroupOption> components, and the framework does not have a good solution in general for this. Hopefully one day the Svelte maintainers will add some way of solving this problem. Until then, you can still style your components using one of the approaches below.

The :global() modifier

The :global() modifier opts your rule out of the default component style scoping. CSS rules inside :global() are treated as “normal” CSS, so the following will work:

<script>
  import {
    RadioGroup,
    RadioGroupLabel,
    RadioGroupOption,
    RadioGroupDescription,
  } from "@rgossiaux/svelte-headlessui";
  let plan = "startup";
</script>

<RadioGroup value={plan} on:change={(e) => (plan = e.detail)}>
  <RadioGroupLabel>Plan</RadioGroupLabel>
  <RadioGroupOption
    value="startup"
    class={({ checked }) => (checked ? "checked" : "")}
  >
    Startup
  </RadioGroupOption>
  <RadioGroupOption
    value="business"
    class={({ checked }) => (checked ? "checked" : "")}
  >
    Business
  </RadioGroupOption>
  <RadioGroupOption
    value="enterprise"
    class={({ checked }) => (checked ? "checked" : "")}
  >
    Enterprise
  </RadioGroupOption>
</RadioGroup>

<!-- WARNING: This works, but may not be desirable; see below -->
<style>
  :global(.checked) {
    background-color: rgb(191 219 254);
  }
</style>

However, global CSS has one critical downside: it’s global! The .checked rule above will now apply to any .checked CSS class in your application, even if it’s in other components. In a larger application, this can get difficult to maintain.

You can scope this more narrowly if you have a wrapper element:

<script>
  import {
    RadioGroup,
    RadioGroupLabel,
    RadioGroupOption,
    RadioGroupDescription,
  } from "@rgossiaux/svelte-headlessui";
  let plan = "startup";
</script>

<div>
  <RadioGroup value={plan} on:change={(e) => (plan = e.detail)}>
    <RadioGroupLabel>Plan</RadioGroupLabel>
    <RadioGroupOption
      value="startup"
      class={({ checked }) => (checked ? "checked" : "")}
    >
      Startup
    </RadioGroupOption>
    <RadioGroupOption
      value="business"
      class={({ checked }) => (checked ? "checked" : "")}
    >
      Business
    </RadioGroupOption>
    <RadioGroupOption
      value="enterprise"
      class={({ checked }) => (checked ? "checked" : "")}
    >
      Enterprise
    </RadioGroupOption>
  </RadioGroup>
</div>

<!-- This will only apply to .checked elements that descend from this component -->
<style>
  * > :global(.checked) {
    background-color: rgb(191 219 254);
  }
</style>

In this case, this works perfectly.

When using this scoped * > :global() approach, keep in mind these principles:

  • Your Headless UI components must be contained inside some element that is rendered by your component, like the wrapper <div> above. You need an element for the Svelte scoped class to be attached to.
  • Your rule will be scoped to your component and any descendant. In the example above this is not a problem, since the component has no <slot>s. But if your component does have <slot>s, you will still need to be careful for collisions.
  • This rule won’t work properly inside a portal, which means that it won’t work properly for a <Dialog>. To style the <Dialog>, you will need to either 1) use global styles without the * > scoping, 2) put a wrapper element inside the <Dialog> and style that instead, or 3) use one of the other styling approaches.

Inline styles

You can style any component using inline styles using the style=... prop:

<script>
  import {
    RadioGroup,
    RadioGroupLabel,
    RadioGroupOption,
    RadioGroupDescription,
  } from "@rgossiaux/svelte-headlessui";
  let plan = "startup";
</script>

<div>
  <RadioGroup value={plan} on:change={(e) => (plan = e.detail)}>
    <RadioGroupLabel>Plan</RadioGroupLabel>
    <RadioGroupOption
      value="startup"
      style={({ checked }) =>
        checked ? "background-color: rgb(191 219 254)" : undefined}
    >
      Startup
    </RadioGroupOption>
    <RadioGroupOption
      value="business"
      style={({ checked }) =>
        checked ? "background-color: rgb(191 219 254)" : undefined}
    >
      Business
    </RadioGroupOption>
    <RadioGroupOption
      value="enterprise"
      style={({ checked }) =>
        checked ? "background-color: rgb(191 219 254)" : undefined}
    >
      Enterprise
    </RadioGroupOption>
  </RadioGroup>
</div>

As mentioned above, this prop accepts a function whose input will be the component’s slot props, allowing you to apply conditional styles.

One downside of inline styles is that you cannot use them to style CSS psuedoselectors, like :focus and :hover. In general this library will provide you with states that make these psuedoselectors unnecessary, however.

Using a CSS framework

You can always use the class prop as normal alongside a CSS framework like Bootstrap, Bulma, Tailwind, etc. The original version of this component library was written by the same team that maintains Tailwind CSS.

Event forwarding

This library will forward all events to the underlying elements, so you can add your own event handlers if necessary:

<script>
  import { Switch } from "@rgossiaux/svelte-headlessui";
  let enabled = false;
</script>

<Switch
  checked={enabled}
  on:change={(e) => (enabled = e.detail)}
  on:click={() => console.log("Clicked!")}
>
  Toggle me!
</Switch>

Note that the library already handles all of the keyboard and mouse events you’d need to implement the component functionality. You don’t need to add a handler to e.g. open or close a popover when the popover button is clicked.

You can also use event modifiers, but they must be separated by ! instead of the normal |, for technical reasons:

<script>
  import { Switch } from "@rgossiaux/svelte-headlessui";
  let enabled = false;
</script>

<Switch
  checked={enabled}
  on:change={(e) => (enabled = e.detail)}
  on:click!once={() => console.log("Clicked!")}
>
  Toggle me!
</Switch>

Using Svelte actions

You can use actions with this library as well. The Svelte use: directive is not supported for Components, so you need to use the use prop instead:

<script>
  import { Switch } from "@rgossiaux/svelte-headlessui";
  import { action1, action2, action3 } from "./my-library";
  let action1options = {};
  let action3options = { foo: true };

  let enabled = false;
</script>

<Switch
  checked={enabled}
  on:change={(e) => (enabled = e.detail)}
  use={[[action1, action1options], [action2], [action3, action3options]]}
>
  Toggle me!
</Switch>

The use prop must be a list of [action] or [action, options] items. These actions will be applied to the underlying element that the component eventually renders.

Customizing the elements rendered

Each component in this library renders as some specific HTML element. By default, this element will be something that makes sense for that particular component: a Label component renders as <label>, a Button component renders as a <button>, etc. These defaults can be found on each component’s page.

You’re free to change the element that any component renders as using the as prop, which is available on every component. This prop can take a string referring to an HTML element to use instead:

<script>
  import {
    Menu,
    MenuButton,
    MenuItems,
    MenuItem,
  } from "@rgossiaux/svelte-headlessui";
</script>

<Menu>
  <MenuButton>More</MenuButton>
  <!-- Render a `ul` instead of a `div` -->
  <MenuItems as="ul">
    <!-- Render a `li` instead of an `a` -->
    <MenuItem as="li" let:active>
      <a href="/account-settings" class={`${active ? "active" : "inactive"}`}
        >Account settings</a
      >
    </MenuItem>
    <!-- ... -->
  </MenuItems>
</Menu>

Due to technical limitations in Svelte, each one of these elements must be handled individually by the library. Most elements should be supported, but some uncommon ones might not be. If you find yourself wanting to use an element that is not currently supported, please file a GitHub issue.