2023-10-14
James Diacono
james@diacono.com.au
Styling web applications is hard. CSS is a poor fit for application development, but the alternatives are severely limited. In practice, we are forced to depend on some kind of style system to manage our styles.
Here are four things I want from a style system:
I could not find a tool that satisfied all of these requirements, so I wrote Fstyle.
If you are a web developer,
Immediately below this paragraph you should see an animated progress bar. You can simulate the amount of progress by dragging the slider beneath it. The links in the heading control whether it is rendered with Vue or React.
The component's implementation presents two challenges. Firstly, it is obviously dynamic. Its appearance changes in response to events. Secondly, it uses a CSS animation. This means that manipulating the style
property of its DOM elements will not work. The full power of CSS is required.
The source code of the component is listed below. Code in
is used only by the Vue implementation, whereas code in
is used only by the React implementation. Code in this colour
is used by both.
We begin by importing the necessary modules. There are some interesting equivalences. React's
is so similar to Vue's
function that we rename it
. The
function serves the same purpose as the
function, likewise
and
.
import fstyle from "https://james.diacono.com.au/files/fstyle.js";
import {
h,
ref,
readonly,
watchEffect,
onUnmounted,
createApp
} from "https://esm.sh/vue@3";
import {
createElement as h,
useState,
useEffect,
useRef
} from "https://esm.sh/react@17";
import {render} from "https://esm.sh/react-dom@17";
We define some stylers. Each styler is used to style an element in the component. demo_styler
positions the progress bar, the slider, and the text within the containing element. Each []
is replaced by the generated class f0⎧demo⎭
in the resulting CSS.
const demo_styler = fstyle.css(function demo() {
return `
.[] {
display: flex;
flex-direction: column;
align-items: center;
}
.[] > :not(:last-child) {
margin-bottom: 20px;
}
`;
});
The progress_bar
styler styles the progress bar, consisting of a parent and a child element. Supposing the percentage
parameter was 50
, then the child element would fill the left half of the parent and []
would be replaced with the generated class f1⎧progress_bar⎭·percentage→50
.
const progress_bar_styler = fstyle.css(function progress_bar({percentage}) {
return `
.[] {
display: block;
overflow: hidden;
border-radius: 10px;
width: 80%;
box-shadow: 0px 5px 15px rgba(0, 0, 0, 0.3);
background: white;
}
.[] > * {
display: block;
overflow: hidden;
width: ${percentage}%;
height: 40px;
}
`;
});
The stripes
styler gives an element an animated stripey background. The thickness of the stripes, their colour, and the duration of the animation cycle are all provided as parameters.
const stripes_styler = fstyle.css(function stripes({
scale,
primary_color,
secondary_color,
duration
}) {
return `
@keyframes [] {
from {
background-position-x: ${scale};
}
to {
background-position-x: 0;
}
}
.[] {
--thickness: calc(${scale} / 2.8282); /* 2√2 */
background-color: ${secondary_color};
background-image: repeating-linear-gradient(
45deg,
transparent,
transparent var(--thickness),
${primary_color} var(--thickness),
${primary_color} calc(2 * var(--thickness))
);
background-size: ${scale} 100%;
animation: [] ${duration} linear infinite;
}
`;
});
We create an Fstyle context to manage the injection of CSS onto the page.
const context = fstyle.context();
The
function is a Vue composable that serves as the glue between Fstyle and Vue. It is passed a
function that is made available for the lifetime of the component. It returns a ref containing an array of class strings to be applied to an element. (A ref is an object with a reactive “value” property.) The
function is invoked within
so that any reactive side effects, such as the reading of props, cause the handle to be recreated.
function use_fstyle(styler) {
let classes = ref([]);
let handle;
watchEffect(function watcher() {
const requireable = styler();
const new_handle = context.require(requireable);
if (handle !== undefined) {
handle.release();
}
classes.value = new_handle.classes;
handle = new_handle;
});
onUnmounted(function () {
handle.release();
});
return readonly(classes);
}
The
function is a React hook. It is like the
composable except for two important differences:
requireable
instead of a styler function. A requireable is obtained by calling a styler, possibly with parameters.Some unreactive state, equivalent to the
variable, is maintained via
.
function use_fstyle(requireable) {
const new_handle = context.require(requireable);
const handle_ref = useRef();
if (handle_ref.current !== undefined) {
handle_ref.current.release();
}
handle_ref.current = new_handle;
useEffect(
function on_mount() {
return function on_unmount() {
handle_ref.current.release();
};
},
[]
);
return new_handle.classes.join(" ");
}
Now we begin the component
function, defining our component. The progress, initially 75%, is stored as a percentage. In Vue, this number is held in a
ref. React uses a
number and a
function to represent the value reactively.
function component() {
const percentage = ref(75);
const [percentage, set_percentage] = useState(75);
We make and “use” the necessary stylers. In Vue, the
ref must be unwrapped. In React,
is a number and can be used directly.
In React, each styler is called immediately to obtain a requireable. In Vue, the call is wrapped in a function that will be sensitive to any reactive effects within. In this case, any change to
will cause the handle to be recreated.
const demo_classes = use_fstyle(() => demo_styler());
const bar_classes = use_fstyle(() => progress_bar_styler({
percentage: percentage.value
percentage
}));
const filling_classes = use_fstyle(() => stripes_styler({
scale: "130px",
duration: "1s",
primary_color: "yellow",
secondary_color: "gray"
}));
During each render, the relevant classes are applied to the <progress_bar>
element and its child, <progress_filling>
. The h
function returns an object (called a “React element” in React and a “vnode” in Vue) describing the desired state of the DOM. We have invented our own element names, "progress_bar"
and "progress_filling"
, to improve readability.
In Vue, the render_progress_bar
function is reactive to any change in
or
.
function render_progress_bar() {
return h(
"progress_bar",
{class: bar_classes.value},
{class: bar_classes},
h(
"progress_filling",
{class: filling_classes.value}
{class: filling_classes}
)
);
}
Adjusting the slider updates the percentage
.
function render_slider() {
return h(
"input",
{
type: "range",
min: 0,
max: 100,
value: percentage.value,
value: percentage,
onInput(event) {
const new_percentage = parseInt(event.target.value, 10);
percentage.value = new_percentage;
set_percentage(new_percentage);
}
}
);
}
function render_demo() {
return h(
"div",
{class: demo_classes.value},
{class: demo_classes},
[
render_progress_bar(),
render_slider(),
h("small", {}, "Rendered with Vue.")
h("small", {}, "Rendered with React.")
]
);
};
Here is the crucial difference between Vue and React. In Vue, the component
function is called only when the component is created. A render function is returned, and is invoked whenever the component needs to be rerendered. On the other hand, component
(and hence render_demo
) is called for every render in React.
In short, Vue takes advantage of closures whereas React does not.
return render_demo;
return render_demo();
}
Now that the component
function has been defined, we hand it off to the framework for rendering.
const element = document.querySelector("#demo");
createApp({setup: component}).mount(element);
render(h(component), element);