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>