One of my big goals with Kelp, my UI library for people who love HTML, was build-free customization.
I want to use the best of modern HTML, CSS, and JavaScript to let people customize the hell out it without every having to open up terminal or run a build step.
Today, I wanted to share how I decided to approach modularizing CSS and customizing which components get loaded. Let's dig in!
The code structure
Kelp's CSS is very modular.
I have a bunch of small CSS files in a bunch of directories that make it a lot easier for me to manage the code.
For development purposes, I'm using the native CSS @import
rule. This lets me edit code and immediately see my changes without waiting for a build.
Going into this project, I had viewed that as a "development code only" state.
A build for me but not for thee
My original plan was for me to have a build step that would prevent an end-user from needing one.
I had planned to add ESBuild to my deploy process. Then, I would compile my modular CSS files into a handful of pre-compiled bundles…
-
kelp.complete.css
with literally everything. -
kelp.core.css
with just the core files. -
kelp.layout.css
with only the layout files. -
kelp.utilities.css
with only the utility classes. - And so on and so on…
This would make it easier for developers to just grab what they wanted without having to grab everything or run their own build.
But then I started working with the code more…
Lots of little edge-cases
As I started working with Kelp to build the docs site itself, I started thinking through various use-cases…
- What if someone wants just the
.container
and .grid
classes, but none of the other layout options? - What if someone needs just three or four utility class options, but not all of them?
- How I handle the myriad of component options as they get built out?
It would be really easy to end up with dozens or even hundreds of compiled files to account for all of the different combinations some might need.
Technically, it can be done.
But is that actually a better user experience? I don't think it is.
Embracing the @import
For as long as I've been a developer, the CSS @import
rule has been frowned upon.
CSS is render blocking, and in the HTTP/1 world, where only two files could be downloaded at a time, lots of CSS @import
's could create substantial render delays.
But in the HTTP/2 world, I've started to question this old wisdom.
I tested this a few years ago and found that using @import
resulted in render times that were 400ms slower on a 3G connection.
Harry Roberts from CSS Wizardry ran a similar test and found larger systems, like Bootstrap, were substantially slower.
But I'm using this approach on the KelpUI.com website right now, and it's fast as hell!
On a high-speed connection, the site starts rendering in about 1100ms (1.1s) on initial render, and 700ms (0.7s) on susequent renders. On a 3g connection, it takes 4s on initial render, and 1.7s on subsequent renders.
Would a concatenated build be faster? Yes, definitely.
Is using @import
slow? I personally don't think so.
The benefits of this approach
I love the experience of being able to just edit a file, open a browser, and see my changes live!
But this approach has another really nice benefit: "buildless builds."
Let's say you want to use Kelp, and want to customize it to only include the components you need.
In a build-process world, you'd need to…
- Delete or comment out the
@import
statements for files you don't want. - Open terminal.
- Run
npm install
- Run
npm build
But using @import
natively, there's a single step: comment out what you don't want.
/* All of the core files, then... */ /* Layout */ @import "./layout/containers.css"; @import "./layout/grid.css"; /*@import "./layout/stack.css"; @import "./layout/split.css"; @import "./layout/cluster.css"; @import "./layout/sidecar.css";*/ /* Components */ @import "./components/callouts.css"; /*@import "./components/avatar.css"; @import "./components/skeleton.css";*/ /* Utilities */ /*@import "./utilities/fill.css"; @import "./utilities/aspect-ratios.css";*/ @import "./utilities/text.css"; @import "./utilities/size.css"; @import "./utilities/margin.css"; @import "./utilities/padding.css"; /*@import "./utilities/flex.css"; @import "./utilities/gap.css"; @import "./utilities/align.css"; @import "./utilities/justify.css";*/ @import "./utilities/accessibility.css";
The developer experience on this is hard to beat, and the end-user experience is relatively the same (though I know some folks will disagree with me about that).
Some caveats and considerations
There are some caveats to everything I've written up to this point.
Don't nest your @import
statements
My arguments about performance being relatively the same completely fall apart if you start nesting your @import
statements.
For example, if my main kelp.css
file looked like this…
@import "./layers.css"; @import "./native.css" @import "./layouts.css" @import "./components.css" @import "./utilities.css"
And then each of those had its own @import
statements…
@import "./layout/containers.css"; @import "./layout/grid.css"; @import "./layout/stack.css"; @import "./layout/split.css"; @import "./layout/cluster.css"; @import "./layout/sidecar.css";
Performance would start to bog down quick because of the waterfall effect this would have on render blocking.
Keep things to a single layer and you're probably good.
Big libraries will take longer
Kelp is relatively light and lean.
If the CSS was in the 100s of KBs, concatenated CSS would start to look a lot better in terms of performance.
I suspect much of "how close" the performance data was is related to how small the overall bundle is.
You can use <link>
instead
While still slower than a concatenated file, loading a bunch of modular files with the <link>
element is marginally faster than @import
.
<link rel="stylesheet" type="text/css" href="./css/layers.css"> <link rel="stylesheet" type="text/css" href="./css/theme/palette.css"> <link rel="stylesheet" type="text/css" href="./css/theme/colors.css"> <link rel="stylesheet" type="text/css" href="./css/theme/fonts.css"> <link rel="stylesheet" type="text/css" href="./css/theme/sizes.css"> <!-- ... -->
It gets really tedious, but if you have a CMS that can handle it for you, maybe not so bad!
What about cache busting?
The way the JSDelivr CDN works, all of the @import
files have a versioned URL. Updating to a new version automatically clears the old browser cache.
If you're downloading Kelp and running your own setup locally, though, cache busting is a problem you'd need to figure out.
A buildless builder
I just finished building a custom theme builder, and am now focused on a buildless custom install tool.
The idea is that you can select which components you want, and it will spit out a custom CSS file for you.
I want to add cache-busting query parameters, let users toggle between @import
and <link>
, use the CDN or a local set, and maybe even create an option to concatenate everything by using fetch()
to grab the CSS files and merge them together.
What I very much want to maintain, though, is that goal I had from the start: Easily customized. No build step.
Learn more about Kelp at KelpUI.com.
Like this? A Go Make Things membership is the best way to support my work and help me create more free content.
Cheers,
Chris
0 Komentar untuk "[Go Make Things] Rethinking modular CSS and build-free design systems"