Building a Svelte Action to Generate Scoped CSS Variables
As I was working on a few housekeeping updates for svelte-themer I was thinking of a way to write an action with the use
directive that leverages the existing codebase to create scoped “themes”, or in this case it would better be described as scoped CSS variables. While we can use themes created by themer by simply setting a theme
prop to theme components, what if we wanted to disconnect from the wrapper and add variables directly. Perhaps these variables are specific to the component we’re building and we just do not feel like writing the variables ourselves. Turns out this was relatively straightforward:
import { createCSSVariableCollection } from "../support/css"
/**
* @typedef {Object} ActionReturn
* @property {Function} [update]
* @property {Function} [destroy]
*/
/**
* use:theme
* @param {HTMLElement} node
* @param {Object.<string, string|number>} theme
* @returns {ActionReturn}
*/
export async function theme(node, theme) {
/**
*
* @param {string} name
* @param {string} value
* @returns {void}
*/
function setProperty(name, value) {
if (!node.style && node.document?.documentElement) {
node.document.documentElement.style.setProperty(name, value)
return
}
node.style.setProperty(name, value)
return
}
function setProperties() {
const variables = createCSSVariableCollection(theme)
for (let [name, value] of variables) {
setProperty(name, value)
}
}
setProperties()
return {
update(newTheme) {
theme = newTheme
setProperties()
},
}
}
This allows us to then use this action in a component and pass a theme
object, which is really just an object of variable names and values (not limited to color tokens).
Using this example on Stackblitz we’re using the action above to add styles to the document root as well as using it to customize theming of a container component.
<script>
import { theme as useTheme } from "svelte-themer/use"
import { presets } from "svelte-themer"
export let theme = {
colors: {
background: presets.dark.colors.background.contrast,
text: presets.dark.colors.secondary,
},
}
</script>
<div class="container-component" use:useTheme="{theme}">
<slot />
</div>
<style>
div {
padding: 2rem;
border-radius: 0.5rem;
background-color: var(--colors-background);
color: var(--colors-text);
}
</style>
Difference with Svelte CSS custom properties
With Svelte’s CSS custom properties we can apply CSS variables directly to a component:
<Container --colors-background={"#bbb"}>
<p>Hello, World!</p>
</Container>
When doing so, Svelte creates a wrapper div
element and applies these properties using inline styles:
In the screenshot above we can see the container-component
class which signifies the container component itself, and when using the action inside the container component instead of creating an additional element with inlined styles the styles are inlined on the component’s element where the action is used. While no functionality is lost either way, styles are applied inline.
In the end the implementation is not all that different, but the usage is different. Svelte CSS custom properties can be applied to components where actions are not. Similarly custom properties are not allowed on DOM elements since they are not valid attributes, but actions can be applied using the use
directive.
Using a Stylesheet
With the same approach, let’s say instead of inlining CSS we create a sort of “scoped” stylesheet. Although <style scoped>
is deprecated, we have the information we need inside the action to create a selector for the element in which the action is applied:
/**
* use:stylesheet
* @param {HTMLElement} node
* @param {Object.<string, string|number>} theme
* @returns {ActionReturn}
*/
export async function stylesheet(node, theme) {
const stylesheet = document.createElement("style")
function setStylesheet() {
const variables = createCSSVariableCollection(theme)
const svelteClass = Array.from(node.classList).find((className) =>
className.startsWith("s-"),
)
let innerHTML = `${node.localName}${svelteClass ? `.${svelteClass}` : ""}{`
for (let [name, value] of variables) {
innerHTML += `${name}:${value};`
}
innerHTML += "}"
stylesheet.innerHTML = innerHTML
node.prepend(stylesheet)
}
setStylesheet()
return {
update(newTheme) {
theme = newTheme
setStylesheet()
},
}
}
Here, we create a style
element and add styles scoped to the parent element:
Again, no overall change in functionality but with the stylesheet
action the outputted HTML will be easier to traverse as the action payload grows.
Final Thoughts
The new theme
action in svelte-themer can be used to circumvent using the existing components like ThemeWrapper
, and although the intention applies to different use cases both are powered by the same core library.