Table of Contents

Themed Svelte Components without CSS-in-JS

Using CSS variables to make easily customisable components

26 Dec 2022. 1258 words.


Motivation

For my React hobby projects, I really loved using Chakra UI and Mantine for a long time because they are very intuitive to use. If you want to make a filled red button, you just write

import { Button as ChakraButton } from "@chakra-ui/react";
import { Button as MantineButton } from "@mantine/core";

function App() {
  return (
    <ChakraButton variant="filled" colorScheme="red">
      Red button
    </ChakraButton>
    <MantineButton variant="filled" color="red">
      Another red button
    </MantineButton>
  );
}

and you’re done! Sadly, I eventually stopped using those UI libraries because of my (and NextJS compiler’s) concerns towards bloated bundle sizes, and the use of CSS-in-JS to achieve easy styling, which the React team now discourages us to use.

However, trying to reproduce this flexibility with plain CSS is hard. I first tried this while going through 30 Days of React tutorials, and it was a hot mess. I now 100% agree that Tailwind is not built for this, but SASS mixins weren’t particularly pleasant either.

I finally had a “Eureka” moment when I was reading at Svelte Docs on style props: I can use CSS variables to dynamically style the components! Of course, I am reinventing the wheel since there are many fascinating UI libraries that only use (S)CSS, such as PrimeReact and Blueprint, but I took this as an opportunity to know more about Svelte and modern CSS.

Now let’s have a look.

Svelte Components with Themes

We will build a component with pre-defined styles that can be toggled with two props: variant and color. For example, to make the following buttons,

Example buttons

we only need the following code:

App.svelte
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
<script>
  import Button from "./Button.svelte";
</script>

<div>
  <Button>
    Gray default
  </Button>
  <Button variant="outline" color="blue">
    Blue outlined
  </Button>
  <Button variant="subtle" color="red">
    Red subtle
  </Button>
  <Button variant="filled" color="green">
    Green filled
  </Button>
</div>

Note Because creating a colour system is a painful task, I will use Radix colors for the component. The library provides CSS variables for their colour scheme, which essentially looks like:

'@radix-ui/colors/gray.css'
1
2
3
4
5
#root {
  --gray1: hsl(...);
  --gray2: hsl(...);
  /* ... */
}

You can, of course, swap these with any colour palettes you have.

Base CSS

Let’s first focus on the base styles, such as typography and padding.

Button.svelte
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<script>
</script>

<!-- passing the event listeners and props -->
<button
  on:click
  {...$$restProps}
>
  <slot />
</button>

<style>
  button {
    padding: 0.6em 1em;
    font-weight: 600;
    border: 1px solid transparent;
    border-radius: 4px;
    cursor: pointer;

    /* for buttons with icons */
    display: flex;
    gap: 0.6em;
    align-items: center;
  }
</style>

We will then add a “default” style to the button, that is, how the button should look like if no props were passed on. My default style is a grey button without border or background, just like the case of Material UI. Don’t forget to add :hover and :active selectors to make the button respond to user actions.

Button.svelte
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<script>
</script>

<!-- passing the event listeners and props -->
<button
  on:click
  {...$$restProps}
>
  <slot />
</button>

<style>
  button {
    padding: 0.6em 1em;
    font-weight: 600;
    border: 1px solid transparent;
    border-radius: 4px;
    cursor: pointer;

    /* for buttons with icons */
    display: flex;
    gap: 0.6em;
    align-items: center;

    /* colors */
    color: var(--gray11);
    background-color: transparent;
    border-color: transparent;
  }
  button:hover {
    color: var(--gray12);
    background-color: var(--gray4);
  }
  button:active {
    background-color: var(--gray5);
  }
</style>

Adding variety with CSS variables

Now, it’s time to add different style and colour options to this component. As mentioned earlier, the component will have four different variant choices and a few colour options:

Button.svelte
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<script lang="ts" context="module">
  export type Color = "gray" | "blue" | "green" | "yellow" | "red";
  export type Variant = "default" | "outline" | "subtle" | "filled";
</script>
<script lang="ts">
  export let color: Color = "gray";
  export let variant: Variant = "default";
</script>

<!-- ... -->

This looks like a painful job to code up the four variants, but it is not as tricky as it seems. Let’s first summarise the text, background and border colours for the variants in a table. Note the “odd” ones are in bold.

DefaultOutlineSubtleFilled
base
textcolor11color11color11white
backgroundtransparenttransparentcolor3color9
bordertransparentcolor7transparenttransparent
:hover
textcolor12color12color12white
backgroundcolor4color4color4color10
bordertransparentcolor8transparenttransparent
:active
backgroundcolor5color5color5color11

Because the variants share a fair amount of settings, we can selectively define CSS variables and provide fallback values, instead of setting up every variable for every variant. For example, only outlined buttons have non-transparent borders, so we can do something like:

Button.svelte
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
<script lang="ts">
  export let variant: Variant = "default";

  let bdColor: string | null = null;
  $: {
    if (variant === "outline") {
      bdColor = `var(--${color}7)`;
    }
  }
</script>

<button style:--btn-bd={bdColor}>
  <slot />
</button>

<style>
  button {
    border-color: var(--btn-bd, transparent);
  }
</style>

By applying this logic to the other CSS properties as well, we have completed the Button component with themes!

Button.svelte
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
<script lang="ts" context="module">
  export type Color = "gray" | "blue" | "green" | "yellow" | "red";
  export type Variant = "default" | "outline" | "subtle" | "filled";
</script>
<script lang="ts">
  export let color: Color = "gray";
  export let variant: Variant = "default";

  // CSS variables
  let vars: { [key: string]: string | null };
  $: {
    // flush the previous values
    vars = {
      text: null,
      textHover: null,
      border: null,
      borderHover: null,
      background: null,
      backgroundHover: null,
      backgroundActive: null,
    };

    // colors
    if (color !== "gray") {
      vars = {
        ...vars,
        text: `var(--${color}11)`,
        textHover: `var(--${color}12)`,
        backgroundHover: `var(--${color}4)`,
        backgroundActive: `var(--${color}5)`,
      };
    }

    // variants
    switch (variant) {
      case "outline":
        vars.border = `var(--${color}7)`;
        vars.borderHover = `var(--${color}8)`;
        break;
      case "subtle":
        vars.background = `var(--${color}3)`;
        break;
      case "filled":
        vars = {
          ...vars,
          text: `white`,
          textHover: `white`,
          background: `var(--${color}9)`,
          backgroundHover: `var(--${color}10)`,
          backgroundActive: `var(--${color}11)`,
        };
        break;
    }
  }
</script>

<button
  style:--btn-color={vars.text}
  style:--btn-color--hover={vars.textHover}
  style:--btn-bd={vars.border}
  style:--btn-bg={vars.background}
  style:--btn-bd--hover={vars.borderHover}
  style:--btn-bg--hover={vars.backgroundHover}
  style:--btn-bg--active={vars.backgroundActive}
  on:click
  {...$$restProps}
>
  <slot />
</button>

<style>
  button {
    padding: 0.6em 1em;
    font-weight: 600;
    border: 1px solid transparent;
    border-radius: 4px;
    cursor: pointer;

    /* for buttons with icons */
    display: flex;
    gap: 0.6em;
    align-items: center;

    /* colors */
    color: var(--btn-color, var(--gray11));
    background-color: var(--btn-bg, transparent);
    border-color: var(--btn-bd, transparent);
  }
  button:hover {
    color: var(--btn-color--hover, var(--gray12));
    border-color: var(--btn-bd--hover, transparent);
    background-color: var(--btn-bg--hover, var(--gray4));
  }
  button:active {
    background-color: var(--btn-bg--active, var(--gray5));
  }
</style>