A Lazy Sequence

CSS grid and scoped component styles.

This site is 20 years old as of November just passed, and to celebrate(?), I’ve rebuilt the whole thing on a new platform for the sixth time. This latest iteration is built using Astro. Unlike prior rebuilds where I’ve brought the stylesheets and templates over from the earlier iteration, I took the opportunity to start them from scratch, using modern CSS features like grid and custom properties, supported by Astro’s component scoped styles.

Which brings us to what I want to talk about today: the interaction between components (particularly in the form presented by the likes of Astro and Svelte, where each component is intended to be an isolated island of markup and styles), and features of CSS like grid which are all about the relationship between one element and it’s children.

For the sake of example, let’s assume a Layout component that is used at the toplevel of a page to manage the primary structure and common elements of a site. This component will have a slot for the content of the page, as well as header and footer, and any other common navigation.

To do this, Layout is using CSS grid rules, with named areas and columns, to describe the organisation of the page, separate components for the Header and Footer, and whatever the specific page is passing in through the content slot.

Here’s a pseudocode for the Layout:

<body>
    <Header />
    <Navigation />
    <main>
        <slot>
    </main>
    <Footer />
</body>

In order for Header to be displayed correctly, we need to specify a style property like grid-area: header; somewhere. This is the puzzle to solve: what is the best factoring of markup, components, and css to describe this.

Let’s examine some different approaches we could take.

Place everything in one file

This is the base case really. If we don’t worry about separating our concerns, then we don’t run into any issues to resolve. The downside is that we don’t get the maintenance wins that seperating out components brings.

This of course ignores the slot for the main content of the page. This is probably not a major issue as, unlike the Header (for example), it’s likely that a wrapper element (such as main, above) will be used to contain the slot’s children.

Wrapper elements in Layout

Introducing a wrapper element makes it trivial to apply styles to position the child components with grid, while also keeping those rules clear of the child components. For example:

<body>
    <header>
        <Header />
    </header>
    <nav>
        <Navigation />
    </nav>
    <main>
        <slot />
    </main>
    <footer>
        <Footer />
    </footer>
</body>

<style>
    body {
        display: grid;
        // …
    }

    // 

    header {
        grid-area: header;
    }

    nav {
        grid-area: navigation;
    }

    main {
        grid-area: content;
    }

    footer {
        grid-area: footer;
    }
</style>

Additionally this has the advantage that if a sub component results in multiple elements, rather than a signal element, they are all captured by the grid correctly.

This is the ideal scenario for separation of concerns, right? For a lot of cases, yes.

However, subgrid (for example) introduces a new complication. Specifically, a subgrid needs to be the immediate child of the grid that it is a subgrid of.

Placing a wrapper in here breaks this requirement. Now, we could make that wrapper a subgrid as well, at the cost of increasing some very specific coupling between the wrapper and child component.

Use :global(…) to punch css rules into children

If we are willing to accept some coupling between the Layout and the child components—not unreasonable for something as specific as a layout—then it might be appropriate to use a :global(…) selector to punch a rule into the child from the parent.

There are two obvious factorings of this, each with different trade-offs. The first uses document position selectors:

<body>
    <Header />
    <Navigation />
    <main>
        <slot>
    </main>
    <Footer />
</body>

<style>
    body {
        display: grid;
        // …
    }

    // 

    body :global(> :first-child) {
        grid-area: header;
    }

    body :global(> :nth-child(2)){
        grid-area: navigation;
    }

    main {
        grid-area: content;
    }

    body :global(> :last-child) {
        grid-area: footer;
    }
</style>

Here, we are relying on knowing that the the markup will always be in a specific order. Assuming each component is always produces exactly one top level element, this is fine. However, it quickly results in brittle code if the markup changes often, or there are lots of elements to wrangle.

The alternative then, is to assume that it’s okay for the Layout to know about the internal structure of the child components. For example:

<body>
    <Header />
    <Navigation />
    <main>
        <slot>
    </main>
    <Footer />
</body>

<style>
    body {
        display: grid;
        // …
    }

    // 

    body :global(> header) {
        grid-area: header;
    }

    body :global(> nav){
        grid-area: navigation;
    }

    main {
        grid-area: content;
    }

    body :global(> footer) {
        grid-area: footer;
    }
</style>

Here we gain some clarity in exchange for tighter coupling with the components that make up the Layout.

Extact root elements from components into Layout

An option that I want to mention for completeness, but that I personally find distasteful is to have wrapper elements, as we do in the second option, but instead of wrapping whatever the component provides, we extract the top level element from the component into the Layout.

For example, if Header was:

<header>
    <h1>Foo</h1>
</header>

we would rewrite it as

<h1>Foo</h1>

and the header element would appear inside Layout.

This feels like it has the most complexity and coupling of all the options. At this point, I think you are better off just inlining the components.

Custom Properties

Some component system, like Svelte, allow passing CSS custom properties to components. You could use this to have the Layout tell the child what area to use, for example:

<body>
    <Header --layout-area='header' />
    <Navigation --layout-area='navigation' />
    <main>
        { @render children() }
    </main>
    <Footer --layout-area='footer' />
</body>
29 December 2024