Jamstacked

In late 2019 the agency I worked for went all in on headless and Jamstack sites. At that time we explored ways to pair Craft CMS with front-end frameworks, like Gridsome, Gatsby, and Nuxt. I used my personal website, wbrowar.com, as my guinea pig, giving me a playground to connect all the puzzle pieces involved in creating a headless and static Jamstack website, such as:

  • Organizing the repos and CI/CD for the back-end and front-end source code.
  • Rendering a static front-end that can dynamically pull from the GraphQL API when using Craft’s Live Preview.
  • Working out incremental builds to reduce time taken during static generation.

I also used my site to explore using Tailwind CSS as the primary method for working with CSS. This worked great when paired with the Vue-based components in the Nuxt-based front-end I decided to go with.

2023 CSS Challenge

2023 has been an epic year for CSS features and web components seem to be all over the front-end zeitgeist right now. I keep reading about all of the new tools us front-end folks have at our disposal and I decided to refactor my personal site to give me a place to try them out. This got me thinking about how I'll do this with the Tailwind-based setup.

First off, Tailwind is not an either use it or don’t use it technology. What I like about Tailwind is that you could decide to style every part of a fully custom site with it, or simply use Tailwind for a few utility classes here and there—mixed in with the rest of your CSS. Yes, there is the part that includes the build step, but with my experience—and with build tools like Vite around—I'm not adverse to post processing my CSS.

But with this refactor, I decided that I wanted to go back to the days before I started using Tailwind, and even earlier in the days before I used SCSS for most of my projects. I wanted to see what I could come up with, architecting a front-end using modern CSS practices. While I'm not shooting for complete Vanilla CSS, I wanted to get back to thinking through a system instead of working within a framework.

Markup Templating

In my day job I use TypeScript, Vue.js, and REST/GraphQL APIs all day, so while that makes maintaining a Jamstack site within my wheelhouse, I no longer felt like I was getting the same level of new learning out of my Jamstack setup as I did back in 2020.

The back end of the site is built in Craft CMS and one of the things I like about Craft is that it’s focused on content management and it doesn't care about what you do on the front end. You can use Craft’s built-in routing and Twig templating setup, you can use the built-in GraphQL setup to feed a headless front end, or you can use something like the Element API plugin to expose your content via JSON API to a headless front end. As the developer, it’s your choice on whether or not you use a front-end framework, and if so, which framework you prefer (with a few caveats around things like working with Live Preview).

Nuxt is really great for static sites, and for a time I also looked into using Astro, but in this new refactor I wanted to get back to defining the markup I need, instead of working within a component-based framework. Part of this goes back to wanting to write CSS for my content and another side of it came from looking at what I need my site to do and what kind of content I was creating. I decided to go back to a non-headless site, built in Twig, with the use of the Craft plugin, Blitz, to handle static caching and cache invalidation.

JavaScript Framework is undefined

With my HTML markup approach decided, next up I wanted to think about how I’ll work with JavaScript for the places where it’s needed. Looking at my current needs, I really only used JavaScript for highlighting code blocks and some minor functionality around image carousels (which is an enhancement on a CSS scroll-snap-based carousel). Programming these couple of bits in vanilla JS wouldn’t be too much work, but rendering the whole page in something like Vue.js is probably overkill.

I like the general setup of Vue components, and in my recent Craft CMS plugin work I got some experience working in Lit to build out web components. So I decided to use web components where it made sense, then go to vanilla JS for more global interactions. SPOILER: So far I haven’t had the need for any global JavaScript and I wound up using just 4 web components for all of the JS on this site:

  • bokeh-box: a component used to generate bokeh-style bubbles for the background of the site.
  • bokeh-controls: a small set of buttons used to define the color scheme of the site and the color of the bokeh bubbles in the bokeh-box component.
  • code-highlight: a wrapper around a <pre><code> block that enables highlighting for a specific language, using highlight.js.
  • element-carousel: takes all of the child elements within the component and turns them into a CSS flex and scroll-snap carousel. The main JavaScript portion is in observing the element currently scrolled in the middle of the page, then indicating that with some dots at the bottom.

My 2023 CSS Solution

I took some time to think about how I’d structure my CSS. This post by Dave Rupert was helpful in introducing me to other approaches and I wound up with a mixture of CUBE principles, organization like Vue’s Single File Component (SFC), and some inspiration from Tailwind.

The sections below describe the problems and areas of my current CSS solution.

Keeping the CSS With the Twig Pieces

One of the features of SCSS that I’ve utilized a lot in the past was that you could break out CSS into a file structure that mirrored the structure of your templates, instead of creating one long CSS file, broken up with comments and other wayfinding tricks. Vue’s SFCs took this even further in that you could write the CSS relevant for a component directly within the component itself.

The value in either approach is in the time saving of being able to open a template file and then work right within its corresponding CSS that is limited to just the scope that the template file is responsible for.

Since I'm working in Twig files, I don't have an easy way to store my CSS in the Twig template files then extract them into a cacheable .css files that can be served on my front-end. I know there is probably a solution here around using PHP or using something like Node to extract the CSS from the files in the filesystem, but the benefit of writing CSS into a Twig file isn’t worth adding a complicated setup into the mix.

I looked for ways to organize CSS natively, but the closest I could get was to use CSS @import, however, the action of importing happens in the browser, so that would A. lead to some performance woes, and B. wouldn’t work because my Twig templates are not stored in a directory that is exposed to my server’s web root.

There are lots of ways to concatenate and minify CSS with build tools, so at this point I decided that I'd use Vite to handle those tasks.

Vite comes with PostCSS, along with the postcss-import package, that lets you inline files imported via CSS @import. Vite also handles minification during the build step, so between the stock setup I would be all set. The only other package I might consider adding is Autoprefixer, but before doing that, I found that Vite 5 comes with Lightning CSS (included as an experimental feature).

With a solution for bundling CSS in place, I looked at my Twig setup and came up with a structure for my CSS files. My templates directory looked something like this:

templates
├── components
│   ├── article-body.twig
│   └── article-thumb.twig
├── includes
│   └── admin-bar.twig
├── layouts
│   └── global-layout.twig
├── macros
│   └── icons.twig
└── pages
    ├── about.twig
    ├── article.twig
    └── home.twig

My Twig structure could be defined like this:

  • components: self-contained, reusable files included via the Twig include or embed tags.
  • includes: one-off includes that don’t belong in any other directory.
  • layouts: page layouts that are extended by templates in the pages directory.
  • macros: Twig macros that are imported and used throughout the project.
  • pages: templates for a specific page or entry section.

Each of these files could require their own CSS, so my idea was to add a CSS file alongside the Twig template as needed. Here's what I wound up with in this first pass:

templates
├── components
│   ├── article-body.css
│   ├── article-body.twig
│   ├── article-thumb.css
│   └── article-thumb.twig
├── includes
│   └── admin-bar.twig
├── layouts
│   ├── global-layout.css
│   └── global-layout.twig
├── macros
│   └── icons.twig
└── pages
    ├── about.css
    ├── about.twig
    ├── article.css
    ├── article.twig
    ├── home.css
    └── home.twig

The name of each CSS file matches the name of its corresponding Twig file.

In the root directory where all of my other front-end source files are stored I have a CSS file that imports all of these files so that these styles are bundled alongside all of the other global styles for the site. With the exception of some web component styles, all of my styles for the site live in one of these files:

front-end
└── src
    ├── css
    │   └── reset.css
    └── wbrowar.css
templates
├── components
│   ├── article-body.css
│   ├── article-body.twig
│   ├── article-thumb.css
│   └── article-thumb.twig
├── includes
│   └── admin-bar.twig
├── layouts
│   ├── global-layout.css
│   └── global-layout.twig
├── macros
│   └── icons.twig
└── pages
    ├── about.css
    ├── about.twig
    ├── article.css
    ├── article.twig
    ├── home.css
    └── home.twig

When bundled, all of these files are imported and minimized, then served on the front end as wbrowar.css (along with a unique content hash for cache busting upon changes).

CUBEish CSS, Nesting, and the Freedom of Cascade Layers

Early into my career in writing CSS I had latched on to the pattern of OOCSS. As I started working with teams of developers we gravitated towards BEM and eventually stayed there for a while. Our rule for writing CSS was if you were going to style something you’d use a class, and with BEM you’d be descriptive and you’d name everything in a way that was unique and described within the context the element is used.

So we'd wind up with markup that looked a lot like this:

<nav class="main_menu">
  <ul class="main_menu__items">
    <li class="main_menu__item main_menu__item--home">
      <a class="main_menu__item__link" href="#">Home</a>
    </li>
    <li class="main_menu__item main_menu__item--about">
      <a class="main_menu__item__link" href="#">About</a>
    </li>
    <li class="main_menu__item main_menu__item--contact-us">
      <a class="main_menu__item__link" href="#">Contact Us</a>
    </li>
  </ul>
</nav>

In this case .main_menu was sort of a class that gave you context for which <nav> element this was (amongst maybe a .footer_menu or a .secondary_menu). Then everything else gets prefixed with that context along with what the element is (in regards to how it will be used or styled), then any modifiers that describe something unique about the element.

In our minds, this was descriptive and it solved problems like clashing styles and being able to use ⌘+F to hunt for this style amongst all of our stylesheets in our _scss directory. Over time this also led to some other issues that made us less efficient:

  • Our BEM setup was based around very unique class names, but it also meant that we either had a lot of duplicate CSS code to cover common patterns in unique contexts, or it meant a rat king of selectors and modifiers everywhere the minute you moved something or tried to re-use CSS.
  • Naming took a while to get right. Some things are easy when there are only one of them on the page, but when you had common things, like headers, you have to do more to define them into sections like, .content_header, .sidebar_header, .news_card_header. Sometimes designers would make a decision after seeing something in the browser, so maybe the news cards got moved into the sidebar... Now you are either renaming a bunch of classes, or you have some no-longer-obvious element names.
  • As much as some folks may find a bunch of Tailwind classes hard to skim and quickly parse, reading BEM code—sometimes compiled via includes and partials—can also look like a wall of underscores and dashes amongst a lot of repetitive names. Indenting source code helps, but some extra mental processing time is required.
  • Gzipping CSS files is great when working with BEM, but when you don't have the ability to set that up where your static assets are served, you wind up with extra file bloat in both your CSS and in your template files.

Instead of following my old BEM rules, I’ve combined Cascade Layers, nesting, child selectors, and parts of CUBE CSS to come up with my new CSS setup.

To start, I’ve created a set of layers and I've imported all of my CSS files into their designated layers:

wbrowar.css
@layer reset, admin-bar, globals, components, theme, layout, page;

/*
 * reset: normalize browser defaults, remove margins, set box-sizing: border-box on everything 
 * admin-bar: layers the styles that come with Admin Bar here so they can be overwritten later
 * globals: set project-specific HTML defaults and global classes
 * components: include styles specific to re-usable Twig components
 * theme: define custom properties for colors, fonts, animation, and spacing
 * layout: styles specific to the global page layout
 * page: page-specific styles
 */
 
@import './css/reset.css' layer(reset);

@import '../../templates/components/article-body.css' layer(components);
@import '../../templates/components/article-thumb.css' layer(components);

@import '../../templates/layouts/global-layout.css' layer(layout);

@import '../../templates/pages/about.css' layer(page);
@import '../../templates/pages/article.css' layer(page);
@import '../../templates/pages/home.css' layer(page);

The @import is a relative path from my front-end source directory to my templates directory.

Adding these layers make it so I don’t need to worry as much about clashing styles or using hyper-specific class naming. This lets me go back to using more global classes, like a .button class, knowing that I can overwrite it on the components or page layers later.

There is still the problem of selecting elements within the same layer, and that's where CUBE and child selectors come in. The things I took most from the CUBE were the composition (C) and the block (B) principles, along with the visual benefit of grouping with brackets (as seen in the CUBE documentation).

Take a look at the markup for my About page for example:

templates/pages/about.twig
  <article class="[ page : about ] content-container">
    <div class="[ content ] prose">

      {# Intro text ... #}
      
      <div class="[ grid ]">
        {# Links to external URLs ... #}
      </div>
    </div>
  </article>

The first thing you might notice are the bracket groupings here. When you look at the source code it makes it really easy to see where the different sections of code are. I set up sort of a framework for thinking about how these blocks work together with the elements on the page. Here’s a general outline for how I’ve organized this:

  • page is a root level starting point and it is the singular name of the folder that this Twig file lives in.
  • Where page describes the directory this file is in, about describes the file name. All of the styles in the about.css file are nested within .page.about.
  • Any child bracket, like [ content ], gets selected using a child selector, .page.about > .content. Then later, .page.about > .content > .grid. This is done via native CSS nesting.
  • .content-container and .prose are global classes that are defined on the globals cascade layer. Because the CSS in about.css is on the page layer, it’s a lot easier to overwrite properties defined on the globals layer.
  • The grid element contains a few <a> elements, so instead of naming those elements with a class like we would have in BEM, this setup makes me confident that I can set properties using this selector, .page.about > .content > .grid > a.
  • I haven’t done anything too complicated yet, but my goal is to limit the amount of child brackets to avoid nesting too much. If needed, I could break things out in to components, but so far that hasn’t been necessary.

Here’s what the full CSS for the About page looks like:

templates/pages/about.css
.page.about {
  container-name: page-article;
  container-type: inline-size;

  & > .content {
    margin: 0 auto;
    padding-top: 100px;
    padding-bottom: 100px;
    max-width: 1024px;

    & h1 {
      font-family: var(--font-futura);
      font-size: 2rem;
      font-weight: var(--font-normal);
      color: var(--color-orange);
    }

    & > .grid {
      --svg-width: 1.5em;
      display: grid;
      grid-template-columns: var(--grid-template-columns, 1fr);
      align-items: center;
      gap: var(--xl);
      margin-top: 50px;
      font-size: 2em;

      & a {
        display: grid;
        grid-template-columns: max-content 1fr;
        align-items: center;
        gap: var(--md);
        text-decoration: none;
        color: var(--color-blue);
        transition: color var(--duration) ease-out;

        &:hover {
          color: var(--color-orange);
        }
      }

      @container page-article (width > 600px) {
        --grid-template-columns: 1fr 1fr;
      }
      @container page-article (width > 900px) {
        --grid-template-columns: 1fr 1fr 1fr 1fr;
      }
    }
  }
}

Global Styles and Custom Properties

My main CSS file, wbrowar.css, imports all of the CSS from my templates directories, and it also includes the CSS for the globals and themes layers. My globals layer contains commonly used classes, like an .article-grid class that is used to display article thumbnails across several pages. It also defines basic element styles, like the styles for <hr> elements.

Because I use Markdown for a lot of the content in my site, the .prose class handles styling some commonly used HTML elements rendered by Markdown:

wbrowar.css
.prose {
  & > * + *:not(hr) {
    margin-block-start: var(--md-rem);
  }

  & :is(blockquote, dd, dl, h1, h2, h3, h4, h5, h6, hr, p, li, pre) {
    max-width: var(--prose-max-width, 70ch);
    font-size: var(--prose-font-size, 1.25rem);
    line-height: var(--prose-line-height, 2);
    text-wrap: pretty;
    color: var(--prose-color, var(--color-black));

    & a {
      color: var(--color-orange);

      &:hover {
        color: var(--color-blue);
      }
    }
    & code {
      padding: 0.4em;
      background-color: color-mix(in srgb, var(--color-blue), transparent 50%);
      border-radius: 0.3em;
      font-family: monospace;
      font-size: 0.9em;
      user-select: all;
      color: var(--prose-code-color, var(--color-blue-800));

      @media (prefers-color-scheme: dark) {
        & {
          color: var(--prose-code-color, var(--color-blue-300));
        }
      }
    }
  }
  & :is(ol, ul) {
    & li {
      & + li {
      	margin-block-start: var(--sm-rem);
      }
      
      &::marker {
        color: var(--color-red);
      }
    }
  }

  @media (prefers-color-scheme: dark) {
    & {
      --prose-color: var(--color-gray-200);
    }
  }
}

The great things about the layer setup is that if I wanted to overwrite a <ul> on a specific page, I won’t need to specify .prose in the selector, but instead I would follow the rules for the file I’m working in. I did include a few CSS Custom Properties here just in case I want to overwrite something and then use that variable to compute something else in all .prose elements.

The name "prose" might seem familiar to someone who uses Tailwind CSS, and specifically the Tailwind Typography plugin. I’m sure other folks have used the term "prose" before Tailwind came along, but that’s where I first heard of the concept and the name for it.

I got a lot of inspiration for my theme layer from Tailwind CSS. Although I wound up not adding any utility classes (yet), I took a lot of naming from Tailwind.

wbrowar.css
@layer theme {
  :root {
    /* Colors */
    --color-black: oklch(27.68% 0 0);

    --color-gray: oklch(57.51% 0 0);
    --color-gray-100: oklch(91.58% 0 0);
    --color-gray-200: oklch(82.3% 0 0);
    --color-gray-300: oklch(73.49% 0 0);
    --color-gray-400: oklch(66.06% 0 0);
    --color-gray-500: oklch(57.51% 0 0);
    --color-gray-600: oklch(48.37% 0 0);
    --color-gray-700: oklch(30.9% 0 0);
    --color-gray-800: oklch(27.68% 0 0);
    --color-gray-900: oklch(16% 0 0);

    /* OTHER COLORS ... */
  }
}

For starters I kept the concept of color shades. Part of this was a carry-over from the old Tailwind setup, but I also really appreciate the idea of limiting your color palette to a fixed number of shades. While I understand we could use color-mix() to automate shades now, I specifically like going through and defining each shade for myself (although automating it might be a good place to start).

wbrowar.css
@layer theme {
  :root {
    /* Font families */
    --font-apple: system-ui, sans-serif;
    --font-avinir: 'Avinir Next', 'Avinir', Helvetica, Arial, sans-serif;
    --font-futura: Futura, 'Trebuchet MS', Arial, sans-serif;

    /* Font weight */
    --font-thin: 100;
    --font-extralight: 200;
    --font-light: 300;
    --font-normal: 400;
    --font-medium: 500;
    --font-semibold: 600;
    --font-bold: 700;
    --font-extrabold: 800;
    --font-black: 900;
  }
}

The fonts setup also comes from Tailwind, at least in the weights naming. I'm not opposed to using the numbers directly in my CSS properties, but the naming is just a tad easier for me to look at and immediately put an image in my head as to what name equates to which weight.

wbrowar.css
@layer theme {
  :root {
    /* Border radius */
    --rounded-sm: .25rem;
  }
}

As time goes on I’ll add more things, like --rounded-sm, to define specific sizes of things I want to use throughout multiple contexts. Using -sm here is a bit of a risk in that it’s possible I won’t want to be limited to sm, md, and lg naming. When that happens I’ll judiciously employ find and replace where I have to 🤓

wbrowar.css
@layer theme {
  :root {
    /* Animations */
    --duration: 0.3s;
  }

  @media (prefers-reduced-motion) {
    :root {
      --duration: 0.01ms;
    }
  }
}

The --duration property is a little different in that I'm using that as a base for animations everywhere. I shouldn't ever set a new value for it, but instead I should use something like transition-duration: calc(var(--duration) * 1.5). This way animations can be cut down to almost 0 when prefers-reduced-motion is turned on. I thought about doing something like this for opacity and prefers-reduced-transparency, but opacity isn't always set the same way and it changes more often that I handle prefers-reduced-transparency in the situation where it needs overriding.

@layer theme {
  :root {
    /* Sizes for layout spacing */
    --xl: 32px;
    --lg: 24px;
    --md: 16px;
    --sm: 8px;
    --xs: 4px;

    --xl-em: 2em;
    --lg-em: 1.5em;
    --md-em: 1em;
    --sm-em: 025em;
    --xs-em: 0.25em;

    --xl-rem: 2rem;
    --lg-rem: 1.5rem;
    --md-rem: 1rem;
    --sm-rem: 0.5rem;
    --xs-rem: 0.25rem;
  }
}

In my opinion, one of the best uses of utility classes is to work with the 4px-based spacing system that Tailwind and other utility design systems tend to use. I didn’t bring over utility classes because so far I haven’t needed to, but I wanted to maintain some consistency in spacing for things like container padding and grid/flex gaps.

I picked up the idea of using really simple size naming from looking at the CSS found in the Craft CMS Control Panel (CP). While there are places where I use one-off values, these sizes are used for most of the padding and spacing across the site.

I added -em and -rem variants to each size so that I could get the same general spacing size at 100%, but to let those spaces change as the base font size changes.

Fin

I’m still living with this new CSS setup, and I’m not sure this is scalable or the right approach for other types of projects, but so far I’ve achieved my goals of being able to write less CSS, to find CSS quickly when making changes, and to write CSS for the needs of my content.

Along the way I’ve made notes about other side effects that I feel like have occurred for me since making this transition:

  • This is the first time in maybe a decade or so where I’ve used things other than classes for CSS selectors (when I am creating the markup). I really like not naming everything, but I also wonder how much more work this will introduce during times where I move things around.
  • My biggest concerns are updates and maintainability—two of the main reasons why I started using Tailwind in the past. We'll see how nesting and using cascade layers works out when it comes time to update or remove sections. Because I am the only developer for my site, I’m not too worried, but I wonder how this sort of system holds up in a team setting.
  • Nesting with BEM led to a lot of extra CSS, but native CSS nesting (even when using & selectors) doesn’t add nearly as much extra file size, and a lot of the same benefits that I like are there.
  • I went all in on size container queries and it has been great. I’m not opposed to using viewport media queries, but I just didn’t wind up needing any. Things like clamp() and container queries made this possible.
  • I have a dark mode theme, but instead of defaulting to inverting all of the color shades, I went through and manually added prefers-color-scheme: dark queries as needed.
  • color-mix() is a CSS dream. It works so well and I think it will help in so many situations where you don’t have full control over the colors you’re working with (such as colors brought in from third-party libraries).
  • I wrapped all of the styles for Admin Bar in a layer so it’s easier to work with a custom cascade layer setup. I really hope UI libraries and other third-party packages start to do this. Imagine a company that provides a third-party form wrapping all of their CSS in a layer instead of using !important everywhere. In fact, @scope, cascade layers, and container queries makes me glad for all of the developers for marketing sites who currently cringe when getting a message saying "just drop this JavaScript tag onto the page".