A CSS-only layout debugger

I've recently come across Gajus Kuizinas' Favorite CSS hack. I had used basic CSS debug styles (also known as diagnostic CSS) before, and kind of love their simplicity. I wondered if I could take them up a notch. The challenge?

Create a CSS-only element inspector: as you hover elements, show their bounding box, and how their descendants are laid out — all with minimal disruption to the layout.

The Approach

You can see what I came up with on this demo page, and follow along as I go into the thought process, the dead-ends, and interesting tidbits of CSS I learned in the process.

Basic styling

To show an element's box, we can outline it:

* {
outline: 1px solid red;
}

The outline property does not alter the layout since it's painted on top of elements, and authors usually only include outline styles for focused elements, so it's relatively innocuous.

To discern how the elements are nested, let's also add onionskin backgrounds:

* {
outline: 1px solid red;
background: rgba(255, 0, 0, 0.1);
}

So far so good! This is starting to look promising, but everything is quite red.

Cycling the hue

To further distinguish elements at different levels of nesting, let's vary the outline / background color (as in Gajus' example). The hsl() notation, which is one of the few ways to obtain color variations in CSS right now, is a good candidate for expressing our colors.

First, a bit of refactoring, to bring red and rgba(255, 0, 0, 0.1) together:

* {
--hue: 0; /* red */
outline: 1px solid hsl(var(--hue), 100%, 50%);
background: hsl(var(--hue), 100%, 50%, 0.1);
}

With the --hue custom property in place, and the outline and background based on it, we can simply assign various values to it and have everything change harmoniously.

Note: HSL is good, but not great. It replaces the machine-oriented red, green, and blue channels with something humans can better relate to — hue, saturation, and lightness. It's not, however, very true to how we perceive colors. Maintaining a constant saturation and lightness, colors of various hues will look wildly brighter or darker to the human eye. When it gets implemented in browsers, the lch() color notation will offer a better approximation.

How do we go about cycling the hue in, let's say, 60 degree increments? You may be thinking, as I did, that CSS custom properties work like normal variables in other programming languages, and reach for:

* {
--hue: calc(var(--hue, 0) + 60);
}

However, CSS disallows cyclic dependencies between custom properties — sets of properties that define themselves in terms of one another — and that includes a property that refers to itself. In a future version of the spec we might get a way to let an element redefine a custom property based on the value it has inherited from its ancestors, but until then, no dice.

Leafing through the CSS values and units spec, I was surprised to find the toggle() function. It enables elements to cycle over a set of values instead of inheriting the value. You'd be able to write:

* {
--hue: toggle(0, 60, 120, 180, 240, 300);
}

...to get elements at each subsequent level to move 60 degrees away from their parent. Nice and clear and... unsupported. Even though the toggle() idea has been floating around since as early as 1999, no browsers implement it at the time of writing.

So, unless I'm missing a clever workaround, we're back to old-school:

* {
--hue: 0;
}
* > * {
--hue: 60;
}
* > * > * {
--hue: 120;
}
* > * > * > * {
--hue: 180;
}
* > * > * > * > * {
--hue: 240;
}
* > * > * > * > * > * {
--hue: 300;
}

This setup caps out at the 300 hue (a nice fuchsia), but you can continue to cycle it for as many * > * selectors as your heart lets you.

Showing the padding around elements

If the browser barely broke a sweat from anything we did so far, it's time to raise the temperature with our next mini-challenge: showing padding visually.

We're going to need something that lets us hook into the element's content box, and its padding box, independently. Hello background-origin!

The background-origin property is only meant for background images, not background colors, so we need a way to make a background image out of a solid color. The CSS Images Level 4 spec defines the image() syntax which accepts a color, such as image(fuchsia), to produce an image. But until browsers support it, we need to improvise with gradients.

The shortest formula to get a solid color with the gradient syntax is, as far as I know:

* {
background-image: linear-gradient(fuchsia, fuchsia);
}

To get different colors for the content box and the padding box, we layer two of these images and define their background-origin separately:

* {
background-image: linear-gradient(fuchsia, fuchsia), linear-gradient(yellow, yellow);
background-origin: content-box, padding-box;
background-repeat: no-repeat;
}

The order of multiple backgrounds is from closest to the user (topmost) to furthest. In the code above, the content-bound fuchsia sits on top of the padding-bound yellow. I never remember this order and have to look it up constantly. It's important to include background-repeat: no-repeat for it to work as expected, which I also tend to forget.

Note: You can probably get a similar effect with background-clip instead of the background-origin / background-repeat combo, but I haven't looked into it.

For a cooler look, like the one you sometimes see in dev tools, let's turn our yellow padding box into nice diagonal stripes. The repeating-linear-gradient is useful for this:

 {
background-image: repeating-linear-gradient(
45deg,
/* angle for diagonals */ fuchsia,
fuchsia 1px,
/* our 1px stripes */ transparent 1px,
transparent 3px /* 2px space between stripes */
);
}

If we work it into our inspector code:

* {
/* Opaque version of the color */
--c-solid: hsl(var(--hue), 100%, 50%);

/* Translucent version of the color */
--c-bg: hsl(var(--hue), 100%, 50%, 0.1);

outline: 1px solid var(--c-solid);

background-image:

/* Content box fill */ linear-gradient(var(--c-bg), var(--c-bg)),
/* Content box white underpaint */ linear-gradient(white, white), /* Padding box stripes */
repeating-linear-gradient(45deg, var(--c-solid), var(--c-solid) 1px, var(
--c-bg
) 1px, var(--c-bg) 3px);

background-origin: content-box, content-box, padding-box;

background-repeat: no-repeat;
}

To make the combo work with stripes, you may notice we've added a coat of white paint over the stripes along the context box, to obscure them.

Making our inspector hover-aware

So far we've done a decent job of highlighting everything on the page. It would be easier to follow if we could localize the highlights to the element we're hovering.

Small problem: when you hover an element on the page, you don't just hover that element. You also hover all its ancestry. And the way CSS is designed does not allow us to select just the innermost hovered element. (We would need to know when a particular element contains another hovered element.)

The closest we can get to the ideal is to change * (every element on the page) to body :hover, body :hover > *, which means hovered elements and their direct descendants. We're also not styling body itself, since it doesn't provide too much information and its add visual noise.

body :hover,
body :hover > *
{
/* Opaque version */
--c-solid: hsl(var(--hue), 100%, 50%);

/* Translucent version */
--c-bg: hsl(var(--hue), 100%, 50%, 0.1);

outline: 1px solid var(--c-solid);

background-image:

/* Content box fill */ linear-gradient(var(--c-bg), var(--c-bg)),
/* Content box white underpaint */ linear-gradient(white, white), /* Padding box stripes */
repeating-linear-gradient(45deg, var(--c-solid), var(--c-solid) 1px, var(
--c-bg
) 1px, var(--c-bg) 3px);

background-origin: content-box, content-box, padding-box;
background-repeat: no-repeat;
}

A note on :focus. Unlike :hover, only one element at a time has :focus, and we could model our approach around that. However, to make all elements focusable, and avoid triggering their default behavior when we click on them, we would need the help of some JavaScript:

Array.from(document.querySelectorAll('*')).forEach(el => {
el.setAttribute('tabindex', 0);
el.addEventListener('click', e => {
e.preventDefault();
e.stopPropagation();
});
});

...which takes away from the beauty of a CSS-only solution.

Extra credits: identifying the elements

One last mini-challenge: could we show information about elements — their tag name, ID and classes – as we hover them?

CSS has the attr() function to read attributes from HTML elements and use them in styles. At the time of writing, we can only use attr() as the content of ::beforeand ::after pseudo-elements, but for our modest goals it will do nicely. We can extract an element's class and id — but not its tag name — and display them as a floating label:

[id]:hover::before,
[class]:hover::before
{
position: absolute;
transform: translate(0, -100%);
background: #000;
color: hsl(var(--hue), 100%, 80%);
}

:not([id])[class]:hover::before {
content: '.' attr(class);
}

:not([class])[id]:hover::before {
content: '#' attr(id);
}

[id][class]:hover::before {
content: '#' attr(id) '.' attr(class);
}

Let's unpack that.

The [id]:hover::before, [class]:hover::before selector matches the ::before pseudo-element of hovered elements which have either an ID or a class attribute attached to them.

Because we can't set these elements' content conditionally (based on which of the ID / class attributes are present) we set it for elements which have both ([id][class]), and separately for ones which have just one (:not([id])[class]) or the other (:not([class])[id]).

position: absolute takes the ::before pseudo-element out of the normal flow. By not specifying top and left offsets we leave it in its default place at the top-left hand corner of its parent's content box. Instead, we use transform(0, -100%) to pull it upwards, so it sits on top of its parent.

Note: Since we're taking over the ::before element, we might break aspects of the layout that may depend on it. And even so, the technique is not bulletproof, and might benefit from setting position: relative on the parent and explicit top and left offsets, with the risk of further altering the original layout.

All together now

Here is the final version of the code:

/* 
Hue rotation
------------
*/


* {
--hue: 0;
}
* > * {
--hue: 60;
}
* > * > * {
--hue: 120;
}
* > * > * > * {
--hue: 180;
}
* > * > * > * > * {
--hue: 240;
}
* > * > * > * > * > * {
--hue: 300;
}
* > * > * > * > * > * > * {
--hue: 0;
}
* > * > * > * > * > * > * > * {
--hue: 60;
}
* > * > * > * > * > * > * > * > * {
--hue: 120;
}
* > * > * > * > * > * > * > * > * > * {
--hue: 180;
}
* > * > * > * > * > * > * > * > * > * > * {
--hue: 240;
}
* > * > * > * > * > * > * > * > * > * > * > * {
--hue: 300;
}

/*
Draw elements' boxes
--------------------
*/


body :hover,
body :hover > *
{
/* Opaque version */
--c-solid: hsl(var(--hue), 100%, 50%);

/* Translucent version */
--c-bg: hsl(var(--hue), 100%, 50%, 0.1);

outline: 1px solid var(--c-solid);

background-image:

/* Content box fill */ linear-gradient(var(--c-bg), var(--c-bg)),
/* Content box white underpaint */ linear-gradient(white, white), /* Padding box stripes */
repeating-linear-gradient(45deg, var(--c-solid), var(--c-solid) 1px, var(
--c-bg
) 1px, var(--c-bg) 3px);

background-origin: content-box, content-box, padding-box;
background-repeat: no-repeat;
}

/*
Show elements' classes / ID
---------------------------
*/


[id]:hover::before,
[class]:hover::before
{
position: absolute;
transform: translate(0, -100%);
background: #000;
color: hsl(var(--hue), 100%, 80%);
}

:not([id])[class]:hover::before {
content: '.' attr(class);
}

:not([class])[id]:hover::before {
content: '#' attr(id);
}

[id][class]:hover::before {
content: '#' attr(id) '.' attr(class);
}

To make it more resilient, we can sprinkle some !important keywords, but that's left as an exercise to the reader.

Here's the demo page again. That is all! ✌️