OpenProps and Shoelace: A better way to style Svelte
At Fablehenge, we love Svelte. When we migrated from React to Svelte we were astonished at how how much better our site performed and how much faster we were able to develop it. The latter is extremely important to us, as we are still only two people and we have to cover development, testing, marketing, customer support, accounting, and everything else that running a business requires.
We originally wrote our Svelte app with flowbite-svelte. Flowbite is a wonderful library to work with. It has a huge collection of components, the pre-chosen styles are pretty good, and it’s straightforward to style. We were able to get up and running extremely quickly.
The only downside of Flowbite is that it is styled with Tailwind CSS.
We tried to love Tailwind
Tailwind is really popular right now, especially in the Svelte community. I’m not sure why the former is true, but the Svelte part is because Svelte doesn’t really have a great mechanism for styling child components from outside. Virtually all Svelte component libraries invite the user to pass a class attribute down to the component, and this attribute is applied by the component on your behalf. Since Tailwind is all about having so-called “utility” classes that are mostly just shortcuts for inline styles, it is a great fit for the “pass a class down” paradigm.
We really gave Tailwind a chance, embedding with it for several months. While Flowbite-svelte allowed us to ship a decent-looking product in record time, we found that trying to style that product using Tailwind was time-consuming, repetitive, tedious, and error-prone. We had to copy-paste inline styles all over the place, or extract them into hard-coded strings and import them as constants.
Eventually, we had to pull in tailwind-merge, which Flowbite already uses under the hood. For me, that was the last straw. Tailwind-merge is an attempt to reimplement the “cascading” part of “Cascading Style Sheets”, but it does it at runtime in Javascript instead of relying on the highly optimized CSS engines that browsers already ship with.
For Jen, the primary annoyance with Tailwind was that the class names are all “slightly wrong” rewordings of CSS that you have to memorize. She says it reminds her of the scene in Neal Stephenson’s Anathem where mathematicians are punished by having to memorize random believable but incorrect facts.
While we are not a company that intends to scale to hundreds or thousands of developers, I have worked with such companies and I have serious doubts that Tailwind would ever be successful in that setting.
That said, I’m not here to talk you out of using Tailwind if you love it. Suffice it to say that I suspect you’ll change your mind someday.
The chief advantage of Tailwind over raw CSS is that it restricts developers to a predetermined set of sizes, colors, radiuses, and other tokens. Tailwind’s recommended set is gorgeous and thoughtfully designed. It’s also not too hard to extend Tailwind with your own theme and custom utilities if necessary. It requires extra knowledge of how Tailwind works, but it isn’t hard.
I looked at what Tailwind was designed to accomplish and I asked myself “Why can’t this be done with CSS Variables?” CSS variables, more formally known as “custom properties” are an excellent fit for design tokens. I believe Tailwind predates custom properties, and would not be surprised if it influenced the decision to add custom properties to the CSS specification. But as of today, I think Tailwind is strictly inferior to CSS-native design tokens.
Open-props
I wasn’t actually looking for an alternative to Tailwind when I stumbled across open-props, but as soon as I saw it I felt a little shiver and thought, “this is it!”
Open props is a beautifully designed set of tokens that you can import into your CSS and access as CSS variables. It has variables for sizes, typography, colours borders, radiuses (including fancy blobs) and some more advanced toys as masks, animations, and easings.
Most usefully, it includes variables that can be combined with media queries to target specific theme preferences and screen sizes. Tailwind has these, too, of course, but these are things that are kind of annoying to target with raw CSS. With open props, it’s dead simple.
Visually, I find open-props to create pages that by default look slightly more “balanced” than Tailwind does. I am very much not a designer, but my first experiment with open-props (a site for my new bookLazyVim for Ambitious Developers) received a surprising number of accolades for how beautiful it was. I cannot take any credit for that. It’s all because of open-props!
Open-props doesn’t have nearly as many tokens as Tailwind CSS simply because
Tailwind classes rename a lot of styles that don’t need to be tokens. For
example, the Tailwind flex
class is used to set display: flex
on an
element. There would be no sense in applying e.g. display: var(--flex)
in
open props for such things.
Open-props doesn’t require any effort to extend. If you don’t like one of their default styles, just overwrite that variable in your base stylesheet. If you think open-props is missing something, just add a new variable.
Further, it is easy to adopt incrementally, no matter what your existing system is. We were able to add open-props to our Tailwind site without breaking it. The default open-props import is just a bunch of custom property . If you don’t use those variables, then nothing happens.
Note that this does not hold true if you import open-props normalize, buttons, or other elements that intentionally overwrite styles. We didn’t do this until we were ready to remove Tailwind in its entirety.
If you want a Tailwind-like experience, you could use open-props with inline
styles, but in my opinion, it works much better if you use it with the
<style>
tag in Svelte.
Styling in Svelte
Svelte has a default styling experience that is a joy to work with if you
don’t use a component library. All you have to do is add a simple html
<style>
tag to a component and style using normal CSS. But Svelte has a
superpower: this style tag is automatically scoped to (a la CSS modules) to the
component you are currently working on.
This means that we don’t need to add a ton of weird class names just to style
things. I don’t struggle to come up with reasonable class names, but more often
than not, html semantic elements such as section
, article
, header
,
footer
, nav
, and aside
are enough to distinguish between elements in a
single component. Many components are pretty small and have just one div
, so
even common tags like that often don’t need a class added. We still add
them as needed, but it’s not a huge deal.
For context, our Tailwind app had nearly 900 class=
attributes in it when we
started the migration to open-props. Now it has fewer than 350. Considering
that the bulk of the styling in our original app was happening inside
Flowbite components, that is an even bigger saving than it sounds.
Svelte has a handy trick to apply a class to an element based on a boolean
value. You can set a class:highlight={get_a_bool_somewhere()}
. I was quite
taken with this minor feature when I first read about it and was therefore
irritated that it isn’t very useful with Tailwind. Tailwind classes typically
have names that break the above syntax. Plus you usually need to apply more than
one style based on a boolean whereas Tailwind classes typically map to single
styles.
With Svelte and open-props, these features just work. Styling custom components is fun and easy again! However, it doesn’t wholly solve the problem of styling child components from outside.
The problem with styling in Svelte
Imagine you have a Widget
component that came from outside your codebase.
Maybe it came from a component library like flowbite-svelte or maybe it’s a
component in a single third-party package such as svelte-select.
Svelte doesn’t have a sanctioned method of applying styles to the html elements
that comprise the Widget
. For example, let’s say that we want our Widget
’s
background colour to be red.
Different Svelte components expose styling on the child component in different
ways. It is extremely popular to use classes, so maybe you could use <Widget class="bg-red-200 />
if the bg-red-200
class is globally available (which it
is in Tailwind).
I’ve also seen some modern Svelte components try to extract styles to css
variables. In this case, you would be able to do something like <Widget --widget-bg="red" />
. The problem with this is that even simple components
have thousands of styles that you may want to change, and the Widget
author
is only likely to expose a subset of them. See the
svelte-select
theming variables for an example.
Svelte does allow us to access child tokens using :global
selectors, but they
unfortunately
discourage using it in
their tutorial.
:global
is an escape hatch to allow you to apply styles outside the css module
scoping. The reason it is discouraged is that you can accidentally impact other
elements in completely different areas of your site.
Let’s say that Widget
has a div class="container"
in it. We could style
that container like this:
<!-- This is not ideal -->
<Widget />
<style>
:global(.container) {
background-color: red;
}
</style>
The problem is that container
is a really popular class name. You probably
don’t want every container
in the app to be red. This can be especially
complicated when working on projects with a large number of devs who can’t all
know what other classes have been globally defined.
This is obviously not ideal. However, you can combine :global
with an object
that is scoped to the module, so only children of the scoped element that match
the :global
will be affected. Consider this:
<div>
<Widget />
</div>
<style>
div :global(.container) {
background-color: red;
}
</style>
There is only one div in this component, and any styles applied to that div
will not affect any other divs. When we combine that div with a
:global(.container)
selector, we can be sure that we are only targeting a
.container
class that is also a child of our one and only div
.
Personally, I don’t have a problem with this kind of scoping. The Svelte tutorial suggests that targeting nested components is “rude”, but it’s my site! The person who created the component doesn’t care how I style it. And indeed, they would probably rather not have to think about how I want to style it. If they don’t have to pass CSS variables or classes forward, their life would be better.
However, though I have no problem with this kind of usage of :global
, it
still makes me nervous because developers unfamiliar with the code might take
the :global
as an example and not realize it’s “only ok if it’s scoped”.
Luckily, it is also possible to use the &
syntax to scope children without
using :global
. I haven’t tested if this is default to svelte-kit or only
because I have post-css enabled to support open-props media queries, but in our
setup, the above widget can also be styled as follows without :global
:
<div>
<Widget />
</div>
<style>
div {
& container {
background-color: red;
}
}
</style>
I’m comfortable with this method of styling child components. It sometimes results in weirdness due to otherwise unnecessary “wrapper” components to attach a scope to, but it is by far the “least bad” method of conveying styling information to a child component.
Unfortunately, the vast majority of Svelte UI component libraries out there don’t work with this.
Shoelace
The most popular Svelte libraries all depend on Tailwind. The only headless libraries of note are Melt-UI and it’s daughter, BitsUI. We tried both of these, but found they require a lot of boilerplate code in a way that was hard to encapsulate.
We experimented with implementing our own design system, but quickly got bogged down in accessibility issues with form elements. Default HTML form elements are not nearly as pleasant to use as the ones that come with component libraries. Things like icons in inputs, error and help text, and labels are simply not a thing with default elements. Sure we could have implemented that ourselves (mostly by copying code from existing component libraries), but it wasn’t something we wanted to maintain at this time.
Then we gave up. We decided that while Tailwind isn’t the best thing to work with, it is the best thing to work with Svelte component libraries. I’m imagining a future where Svelte 5 snippets are used to power a component library that doesn’t require awkward styling hacks, but we aren’t there yet.
And yet… we were so close. So close to a solution. By the time we’d given up, we’d already migrated our blog and marketing sites to open-props and were enjoying development on them. We really wanted to drive it home on the main Fablehenge app.
We knew that web components are a thing, but I was nervous about using them because they aren’t aware of Svelte. However, Svelte is a compiler. There really isn’t anything to be aware of at run time. Further, Svelte can even be configured to build svelte components into web components.
So maybe it’s worth a try, right? I did a lot of research and determined that Shoelace would be a good fit for us. They have comprehensive documentation, highly accessible components, and lightweight default styles that are easy to tweak.
Styles that are easy to tweak. From Svelte, no less!
Styling Shoelace has a bit of a learning curve because you have to understand things like “Shadow DOM” and “HTML Parts”. But these are very well documented on the Shoelace website, and once you get the hang of it, it goes pretty quickly.
Shoelace even comes with its own collection of design tokens, kind of a subset of the ones that open-props exposes. We experimented with them, but ultimately decided to stick with open-props. The open-props system is more comprehensive than Shoelace’s, and we actually don’t use that many Shoelace components. Where we were previously a flowbite-svelte app that had a lot of custom components, we are now a custom app that uses a few Shoelace components.
Shoelace is a pleasure to work with, largely due to the concise, but thorough
documentation. We didn’t find that we suffered much for using a web component
library instead of a Svelte library. The only exception I can think of is that
we can’t do things like bind:value
to get two way binding on an input. We
built a Svelte wrapper of the input element that listens to change events and
updates a two way binding, so we only have that code in one place. All the
other inputs use our wrapper instead. This wrapping wasn’t necessary for
every Shoelace component, but it was easy to do in the cases where it was
helpful.
Theming is the hardest part
Migrating off of Tailwind classes was tedious and time consuming, but straightforward. The various AI chatbots are universally good at “convert this set of Tailwind classes to real CSS”. Styling Shoelace components had a learning curve, but it’s easier to learn than Tailwind itself. More importantly, it doesn’t force you to memorize the wrong names of CSS styles!
The hardest thing to get right was theming. We allow our users to choose light, dark, or system theme. When you’re using three different systems (during the transition, we had Shoelace, Open Props, and Tailwind), it can be tricky to get them to agree on which theme is currently applied! Even taking Tailwind out of the mix required some interesting contortions. Open-props and Shoelace both integrate automatically with the system theme, but if you want to override it as we give our authors the option to do, you have a bit of legwork to do.
One cool side effect of this work is that we now reactively integrate with the system theme. Previously, authors whose OS theme changed based on time of day had to reload the page when the sun set. Now it automatically flips to dark when the rest of the OS does.
How we tackled the migration
The project started with a lot of experimentation on which options to use and whether changing the system would actually benefit us, and we settled on the technologies outlined above.
We started by prototyping a greenfield project using open-props; that became the blog you are currently reading. We migrated the marketing site next. It is simple enough that we didn’t need Shoelace components. Then came the main app, which was the bulk of the work.
Our first step was to add open-props to our app so that it works in parallel with Tailwind. We didn’t bring in open-props.normalize, so this just gives us access to the color and style variables we need. We also had to tweak the vite configuration to support post-css for the custom media queries.
Second, Jen set up a bunch of custom styles for us. This was largely to support
dark mode. In the Tailwind app, we had endless components that had to have
foreground and background colours explicitly set for light and dark themes. In
the new app, we wanted to be able to use variables such as --bg-blue
and
--text-gray
and have it automatically pick the right colour depending on the
theme. This made everything a lot more pleasant AND a lot more consistent.
Next, we created a custom Button
class. Buttons are notoriously difficult in
design systems; they seem so simple (text you can click), but in reality, there
are always a lot of different colours and styles for buttons. We have flat,
bright, and outline buttons, for example. Replacing all the flowbite-svelte
buttons with our own Button
component was a big manual task. We chose not to
use Shoelace’s Button for this, though. It didn’t have quite the structure for
its properties to drop in easily, and since we heavily customize a lot of our
buttons there wasn’t a lot of benefit to reusing their style.
At that point, Jen searched for class=
and we determined we had 900 classes
to visit. That seemed manageable. She started working on that while I focused
on removing Flowbite-svelte.
I did an inventory of the Flowbite-svelte components we use, and divided them up into these categories:
- Things that can be replaced with a standard HTML element with styling: This
included components such as
P
,A
, andHeading
. - Things we definitely want to replace with Shoelace components. The big one
here was
Tooltip
. Tooltips are notoriously hard to get right, and we didn’t want to maintain our own implementation. - Form elements. At this point we weren’t sure if we wanted to replace these with HTML elements or Shoelace elements, but we settled on Shoelace in the end.
- Other things we aren’t sure whether we want to replace with Shoelace or not.
The big ones here were
Accordion
, which can be replaced with html-nativesummary
anddetails
tags or withsl-summary
, andDialog
, which can be replaced withdialog
orsl-dialog
. We decided to use Shoelace for both as the shoelace components have nicer animations.
Then I started migrating them. I had a grep running for flowbite-svelte
and
eagerly watched the counts tick down as I worked. The only difficult part of
this was migrating our Cypress tests. Shoelace sets up totally different
selectors from Flowbite, and accessing stuff inside the shadow dom from Cypress
requires some strange incantations. This part was super boring!
Even though we don’t have anything against Flowbite itself, it was really exciting to merge the “no_flowbite_components” branch! Jen had done most of the Tailwind classes by that point, so I pitched in to help wrap that up.
Then we removed Tailwind from the app and everything looked awful! Tailwind’s
CSS does a lot of normalization for us. We were able to get about 90% of that
back by enabling the open-props/normalize
preset. Then we had to comb through
the app and look for anything that looked off. I immediately found some Tiptap
editors that were missing rounded corners, some typography that was no longer
the right size, and buttons that had the wrong default background colour. These
changes all need to be made in a global stylesheet. They are trivial to make,
but we had to be careful to try and catch them all.
In the end, we have a codebase we are much happier to work on. And the site looks a little better, too! This is partially because we redesigned some components and colours as part of the project, but also because open-props seems to encourage slightly nicer components than Tailwind does.
We’re a little nervous about how Shoelace will behave in the wild, so we’ll be monitoring that closely. Otherwise, we’re looking forward to getting back to new feature development. We have some exciting improvements coming for Feedback, we want to improve our mobile experience, and we have some ideas to help you dive deeper into how multiple tags relate to scenes over time.