ure CSS Mixin for Displaying Values of Custom Properties
Do you write CSS? Do you use custom properties with calculations? Do you want to preview their values while you’re debugging them? What if you could do so by setting just one additional custom property? Without any JS? In this article, I present a native CSS mixin that will output various values as pseudo-elements.
he Problem
Let’s say we have some CSS variable that calculates some value based on cqi
and em
applied to some class .test1
:
.test1 {
--width: calc(50cqi + 2em);
}
We don’t know how wide it is. And browsers don’t know it as well: after all, custom properties are not evaluated until used. No browser devtoolsGo to a sidenote will tell you this. One way to see it is to apply it to something:
.test2 {
--width: calc(50cqi + 2em);
width: var(--width);
padding: 0.5em;
background: var(--PINK);
}
Will the browser devtools help us to know the exact value now? Only if we will go to the “computed style” panel, and look at the final value of the width
property. The computed value won’t be available in the regular “styles” panel, and, as we now have paddingGo to a sidenote, but no box-sizing: border-box
, we can’t even rely on the browser displaying the dimensions.
And then, there is the need to open the devtools in the first place, inspect the exact element, and so on.
What if there is a better way?
he Solution
What if we could previewGo to a sidenote the value without applying it?
.test3 {
--width: calc(50cqi + 2em);
--preview: var(--width); /* ☜ Oh what’s that? */
}
If we want to know the computed value, the only thing we need is to add --preview: var(--width)
, and we get the resultGo to a sidenote!
What if we’re curious about some other things?
<ul>
<li style="--preview: 1rem"> 1rem = </li>
<li style="--preview: 1em"> 1em = </li>
<li style="--preview: 1lh"> 1lh = </li>
<li style="--preview: 1rad"> 1rad = </li>
<li style="--preview: 100vw"> 100vw = </li>
<li style="--preview: pi"> 𝝅 ≈ </li>
<li style="--preview: 2 * pi"> 𝝉 ≈ </li>
<li style="--preview: var(--PINK)">var(--PINK): </li>
</ul>
There are, indeed, many things we can display this way! Ok, maybe we can’t get the exact stringified value of the colors, but we can at least show them as, well, actual colors!
All we had to do: add a single custom property: --preview
. At first, we did it inside a regular rule, then used inline styles directly. Any other wayGo to a sidenote would work as well!
So, what is happening? How does it work? Can you somehow use it for debugging your CSS projects?
uick Take-Away
With this article, I present you a native CSS mixin that allows us to do this (no JS involved). I did also publish an npm package, whichGo to a sidenote, with the help of CDNs like unpkg.com can be a fast way to get this mixin in your CodePens, for example.
If you want to play with this mixin, feel free to use the package, copy the mixin itself, or just open the devtools on this article’s page, and play with the --preview
custom property on any element.
Of course, a necessary disclaimerGo to a sidenote: this is highly experimental! You probably don’t want to include this mixin’s code anywhere near your production code. But in a dev environment? Or on CodePen? That’s the best place for experimental tech like that, where you’re the main user.
he Package
My main use case for it — having it available for any CodePen and other experiments I create, as a quick way to debug things.
It is possible to include the mixin in HTML from any CDN:
<link
rel="stylesheet"
type="text/css"
href="https://unpkg.com/@kizu/mixins@0.1.3/preview.css"
/>
Or from CSS
@import
url("https://unpkg.com/@kizu/mixins@0.1.3/preview.css");
Or you can install itGo to a sidenote from npm: npm install @kizu/mixins
or yarn add @kizu/mixins
, and then include it from there.
I published it in as minimal a setup as I could. I forgot when I published a package the last time, so uh, yeah. Please tell me if you’d like me to make any changes to the package setup (but I do not promise to follow your suggestions). Here is a GitHub repo for it.
he Mixin
You can read the full codeGo to a sidenote of the mixin here under the <details>
. Alternatively, you can read it in full on GitHub as well.
The full code of the mixin (long, open at your own risk!)
/*
Read the full write-up about this mixin:
https://kizu.dev/preview-mixin/
This is a “native” CSS mixin. It relies on
a few specific CSS interactions, see
https://kizu.dev/indirect-cyclic-conditions/
for the details which cover this `--WHEN` custom
property, why we use custom cascade layers, etc.
*/
:root { --WHEN: }
/*
Most of the custom properties inside are registered,
see the list after the mixin.
*/
@layer mixins.preview {
/*
The mixin can be used on any element itself,
or on a pseudo-element directly.
An `::after` will override the element itself.
*/
*,
*::before,
*::after {
/*
The key custom property adding which to
any element turns the mixin on.
*/
--preview:
/*
We need to cross-reference these to create
the initial cyclic deps. See
https://kizu.dev/indirect-cyclic-conditions/
*/
var(--_p-reset)
var(--_p-content)
var(--_p-color)
var(--_p-reset-value)
var(--_p-content-value)
var(--_p-color-value)
/* Not used in the mixin, allows extensions. */
var(--preview--toggle)
;
/*
A space toggle that could be used as an API.
- `initial` when mixin is off.
- empty when it is on.
*/
--preview--toggle: var(--WHEN, var(--preview));
/* Capturing `<dimension>`-like types. */
/*
We need `calc()` to catch any stray math
like `pi`, `2 + 2` etc.
*/
--_p-captured: calc(var(--preview));
/*
The next 5 declarations attempt to cast the
value as `<number>` from different other types.
*/
/*
Nothing special is required for cating
from `<number>`
*/
--_p-from-number: var(--_p-captured);
/*
To cast other dimension-like values we need to
“divide” them by the the same unit to get the
`<number>` in the end. See
https://dev.to/janeori/css-type-casting-to-numeric-tanatan2-scalars-582j
The additional division and multiplication
10000 is a workaround for a Firefox bug:
https://bugzilla.mozilla.org/show_bug.cgi?id=1939353
In the future, we could just do
`calc(var(--_p-captured) / 1px)` etc.
*/
--_p-from-length: calc(
10000
*
tan(atan2(var(--_p-captured), 10000px))
);
--_p-from-angle: calc(
10000
*
tan(atan2(var(--_p-captured), 10000deg))
);
/*
I find it easier to work with `ms`,
so we're accounting for this here.
*/
--_p-from-time: calc(
10000000
*
tan(atan2(var(--_p-captured), 10000s))
);
--_p-from-percentage: calc(
10000
*
tan(atan2(var(--_p-captured), 10000%))
);
/*
The final “value” will have only one of
the components !== 0.
*/
--_p-value: (
var(--_p-from-length)
+
var(--_p-from-number)
+
var(--_p-from-angle)
+
var(--_p-from-time)
+
var(--_p-from-percentage)
);
/*
When trying to output the fractional value with
counters, it is more convenient to work with
the absolute values, and separate the sign.
In the future, it could be just `abs()` and
`sign()` (Chrome, hello?).
*/
--_p-value-abs:
max(var(--_p-value), -1 * var(--_p-value));
--_p-sign:
calc(var(--_p-value) / var(--_p-value-abs));
/*
Here we split our value to contain the integer
and the decimal parts. For the integer, we use
`round()`. The `0.0001` there is a workaround
for a Firefox bug:
https://bugzilla.mozilla.org/show_bug.cgi?id=1924363
*/
--_p-part1:
round(down, var(--_p-value-abs) + 0.0001, 1);
--_p-part2: round(
(var(--_p-value-abs) - var(--_p-part1))
*
/*
While we could implement more than 3 digits
after the dot, it is much more convenient
to work with fewer of them, and we rarely
need more.
*/
1000,
/*
When rounding, we always keep the `1`,
which is required for Safari, as it did not
yet implement the implicit `1` argument.
*/
1
);
/*
We need to further split the fractional part
into separate digits, as otherwise we couldn’t
properly output values like `001` with counters.
*/
--_p-d1: round(down, var(--_p-part2) / 100, 1);
--_p-d2: round(
down,
var(--_p-part2) / 10 - var(--_p-d1) * 10,
1
);
--_p-d3: round(
var(--_p-part2)
-
var(--_p-d1) * 100
-
var(--_p-d2) * 10
,
1
);
/*
Most other calculations were moved into the
`counter-reset` itself, but when we need to
reuse the value, it might be present as a
variable.
The specifics about what this and some
other calculations do are in the full write-up:
https://kizu.dev/preview-mixin/
In short, this checks if we need to display the
zero symbol after the dot.
*/
--_p-zero1: calc(
var(--_p-d1) + max(0, 1 - var(--_p-part2))
);
/*
Checking if the value is empty: `1` when it is,
will use initial value which is `0` otherwise.
*/
--_p-is-empty: 1 var(--preview);
/*
Checking if the value is `initial`/“IACVT”.
As it will also catch the empty value, and we
already checked it, we can narrow it down.
*/
--_p-is-initial:
calc(1 - var(--_p-is-empty)) var(--preview,);
/*
Type-grinding the value to check if it is a
string. It can accept multiple strings, and we
use `""` to exclude numbers.
This means we will later need to exclude empty
values when displaying quotes.
*/
--_p-is-string-or-0: var(--preview) "";
--_p-is-string:
var(--_p-has-strings, var(--_p-is-string-or-0))
var(--_p-no-strings, 0);
/*
We consider the value to be a number if it is
not a string, empty, or initial. This is not
the only check, we will later need to exclude
custom idents via a different technique.
*/
--_p-is-number: max(
0,
1
-
var(--_p-is-string)
-
var(--_p-is-empty)
-
var(--_p-is-initial)
);
/* Type-grinding idents that we know of */
--known-ident: var(--preview);
--_p-is-1-if-known: var(--known-ident);
--_p-is-known: var(--_p-is-1-if-known);
--_p-builtin-ident: var(--preview);
--_p-is-1-if-builtin: var(--_p-builtin-ident);
--_p-is-builtin: var(--_p-is-1-if-builtin);
--_p-is-custom-ident: var(--preview);
--_p-is-none-or-0: var(--_p-is-custom-ident);
--_p-is-none: var(--_p-is-none-or-0);
/*
Checking if the value is a color. `color-mix()`
allows us to switch to a `0` for any non-color
value while excluding numbers.
*/
--_p-is-color-or-0:
color-mix(in hsl, var(--preview) 100%, red 0%);
--_p-is-color: var(--_p-is-color-or-0);
/*
Problem with the `color` is that it will result
in `currentColor`, overriding any color that is
set on the pseudo otherwise. Maybe we could use
keyframed toggles to do this? We could do just
`color-mix(in oklch, var(--preview,), red 0%)`,
but Safari will fail in some cases.
*/
--_p-color: color-mix(
in oklch,
var(--preview,) calc(100% * var(--_p-is-color)),
currentColor
);
/*
Defining the value for `counter-reset`,
with all the parts needed.
*/
--_p-reset:
var(--WHEN, var(--preview))
--_p-is-color var(--_p-is-color)
--_p-is-none var(--_p-is-none)
--_p-is-empty var(--_p-is-empty)
--_p-is-initial var(--_p-is-initial)
/*
Empty value will result in false-positive
is-string, need to narrow.
*/
--_p-quote max(
0,
var(--_p-is-string) - var(--_p-is-empty)
)
--_p-sign var(--_p-sign)
--_p-part1 calc(
/*
Apply the part value only if it is
an actual number.
*/
var(--_p-part1) * var(--_p-is-number)
+
/*
Otherwise, it can be 424242..424244
based on various factors, in which case
it will be omitted, but could be used to
display other values if necessary,
like for idents or colors.
*/
424242 * (1 - var(--_p-is-number))
+
var(--_p-is-builtin)
+
var(--_p-is-known)
+
var(--_p-is-none)
+
var(--_p-is-color)
)
--_p-dot var(--_p-part2)
--_p-zero1 var(--_p-zero1)
--_p-zero2 calc(var(--_p-zero1) + var(--_p-d2))
--_p-part2 calc(
var(--_p-d1)
*
clamp(
1,
10 * (var(--_p-d2) + var(--_p-d3)),
10
)
+
var(--_p-d2)
)
--_p-part3 var(--_p-d3)
/*
As we don’t have `round(from-zero, …)`,
we have to use `abs()` and round up,
so we will get a `0` or a positive
non-fractional number.
Although, as we have the `--_p-sign` we can
use it instead of a `max()` workaround.
If we could do `from-zero`, we could be ok
with negative values too.
*/
--_p-unit-px round(
up,
var(--_p-from-length) * var(--_p-sign),
1
)
--_p-unit-deg round(
up,
var(--_p-from-angle) * var(--_p-sign),
1
)
--_p-unit-ms round(
up,
var(--_p-from-time) * var(--_p-sign),
1
)
--_p-unit-perc round(
up,
var(--_p-from-percentage) * var(--_p-sign),
1
)
;
/* For strings, we just need to ensure the type */
--_p-as-string: var(--preview);
/*
Defining the value for `content`, with all the
parts needed. The second argument is the
`@counter-style`, which determine how to display
things. See the list of them in the end.
*/
--_p-content:
var(--WHEN, var(--preview))
var(--preview-prefix,)
/*
We don’t have a good way to convert an ident
to a string for now, so we have to use a
finite list of custom styles for this.
*/
counter(_, var(--known-ident))
counter(_, var(--_p-builtin-ident))
/*
We can’t have a style with the name `none`,
so we grind for it.
*/
counter(--_p-is-none, --_p-is-none)
/*
The captured value that is used here will
be 424243, and if we don’t change it
otherwise (when we can detect a
non-dimension type), we show `<unknown>`.
*/
counter(--_p-part1, --_p-is-unknown)
/*
Otherwise, we show some other
detected types.
*/
counter(--_p-is-color, --_p-is-color)
counter(--_p-is-empty, --_p-is-empty)
counter(--_p-is-initial, --_p-is-initial)
/*
For strings, we can wrap the value with
single quotes. Note that for multiple
strings inside a value we cannot map
through them sadly.
*/
counter(--_p-quote, --_p-quote)
var(--_p-has-strings, var(--_p-as-string))
counter(--_p-quote, --_p-quote)
/*
The `<dimension>`-like values:
we separate all the parts.
*/
counter(--_p-sign, --_p-sign)
counter(--_p-part1, --_p-as-number)
counter(--_p-dot, --_p-has-dot)
counter(--_p-zero1, --_p-leading-zero)
counter(--_p-zero2, --_p-leading-zero)
counter(--_p-part2, --_p-has-decimals)
counter(--_p-part3, --_p-has-decimals)
counter(--_p-unit-px, --_p-unit-px)
counter(--_p-unit-deg, --_p-unit-deg)
counter(--_p-unit-ms, --_p-unit-ms)
counter(--_p-unit-perc, --_p-unit-perc)
var(--preview-suffix,)
;
}
* {
/*
If we want to use the values calculated on the
element on the pseudos, we need to explicitly
inherit them via separate custom properties.
*/
--_p-reset-inherited: var(--_p-reset);
--_p-content-inherited: var(--_p-content);
--_p-color-inherited: var(--_p-color);
/*
Cyclic toggles for switching the default place
we output the `--preview` when it is defined
on the element itself.
*/
--preview-position: var(--preview--after);
--preview--after: var(--preview-position,);
--preview--before: var(--preview-position,);
}
/*
Saving the values of cyclic toggles with different
names based on position
*/
*::before {
--preview--inherited: var(--preview--before);
--preview--self: var(--preview--after);
}
*::after {
--preview--inherited: var(--preview--after);
--preview--self: var(--preview--before);
}
*::before,
*::after {
/*
If we want to be able to set the value either
on a pseudo, or on the element itself, we need
to have a few more custom properties.
*/
--_p-reset-value:
var(--_p-reset) var(--WHEN, var(--preview));
--_p-content-value:
var(--_p-content) var(--WHEN, var(--preview));
--_p-color-value:
var(--_p-color) var(--WHEN, var(--preview));
--_p-reset-final: var(
--_p-reset-value,
var(--_p-reset-inherited)
);
--_p-color-final: var(
--_p-color-value,
var(--_p-color-inherited)
);
/*
Use different values based on
the `--preview-position`
*/
--preview-content:
var(--preview--self, var(--_p-content))
var(--preview--inherited, var(
--_p-content-value,
var(--_p-content-inherited)
))
;
--preview-reset:
var(--preview--self, var(--_p-reset))
var(--preview--inherited, var(--_p-reset-final))
;
content: var(--preview-content, revert-layer);
counter-reset: var(--preview-reset, revert-layer);
color:
var(
--preview--self,
var(--_p-color, revert-layer)
)
var(
--preview--inherited,
var(--_p-color-final, revert-layer)
)
;
}
/*
Safari < 18.2 does not support `<string>` inside
`@property`, so we have to hide anything related
to it behind space toggles for everything else
to work correctly.
*/
body {
--_p-has-strings: ;
--_p-no-strings: initial;
}
/*
Safari supports container style queries from 18,
so we can use them to detect the `<string>`
support, and if there is no styler queries,
things will still work.
*/
@container style(--_p-as-string: "") {
body {
--_p-has-strings: initial;
--_p-no-strings: ;
}
}
/*
Note: everything below is inside a layer because
otherwise it did not work when used in my blog's
setup, for some reason. But also, no reason to not
add it outside of the layer if we can have it here.
*/
/*
It is possible to override this definition from
outside and provide other defined `@counter-style`
in its `syntax` (while keeping the `empty`).
*/
@property --known-ident {
syntax: "empty";
initial-value: empty;
inherits: false;
}
/*
This is the built-in lists, which shows how it
is done for a few of them.
*/
@property --_p-builtin-ident {
syntax: "auto|max-content|min-content|empty";
initial-value: empty;
inherits: false;
}
/* This is how the known idents should be defined. */
@counter-style auto {
system: cyclic;
symbols: "auto";
}
@counter-style max-content {
system: cyclic;
symbols: "max-content";
}
@counter-style min-content {
system: cyclic;
symbols: "min-content";
}
/* At the moment we don’t cover all `<dimension>`. */
@property --_p-captured {
syntax:
"<number>|<length>|<angle>|<time>|<percentage>";
/*
Will be used to display `<unknown>` when not
matching a dimension, and if it is not modified
by some other detected type.
*/
initial-value: 424243;
inherits: false;
}
/*
Variables for detecting the dimension type: they all
have the same type, but will be called with
different types and used later to display units.
*/
@property --_p-from-number {
syntax: "<number>";
initial-value: 0;
inherits: false;
}
@property --_p-from-length {
syntax: "<number>";
initial-value: 0;
inherits: false;
}
@property --_p-from-angle {
syntax: "<number>";
initial-value: 0;
inherits: false;
}
@property --_p-from-time {
syntax: "<number>";
initial-value: 0;
inherits: false;
}
@property --_p-from-percentage {
syntax: "<number>";
initial-value: 0;
inherits: false;
}
/*
Captured string, so we can easily display it
right away.
*/
@property --_p-as-string {
syntax: "<string>+";
initial-value: "";
inherits: false;
}
/*
Below are all the type grinding that is going on
for detecting various types. See
https://www.bitovi.com/blog/css-only-type-grinding-casting-tokens-into-useful-values
*/
@property --_p-is-string-or-0 {
syntax: "<string>+|<number>";
initial-value: 0;
inherits: false;
}
@property --_p-is-string {
syntax: "<number>";
initial-value: 1;
inherits: false;
}
@property --_p-is-empty {
syntax: "<number>";
initial-value: 0;
inherits: false;
}
@property --_p-is-initial {
syntax: "<number>";
initial-value: 0;
inherits: false;
}
@property --_p-is-custom-ident {
syntax: "<custom-ident>";
initial-value: not-none;
inherits: false;
}
@property --_p-is-none-or-0 {
syntax: "none|<integer>";
initial-value: 0;
inherits: false;
}
@property --_p-is-none {
syntax: "<integer>";
initial-value: 1;
inherits: false;
}
@property --_p-is-1-if-builtin {
syntax: "empty|<integer>";
initial-value: 1;
inherits: false;
}
@property --_p-is-builtin {
syntax: "<integer>";
initial-value: 0;
inherits: false;
}
@property --_p-is-1-if-known {
syntax: "empty|<integer>";
initial-value: 1;
inherits: false;
}
@property --_p-is-known {
syntax: "<integer>";
initial-value: 0;
inherits: false;
}
@property --_p-is-color-or-0 {
syntax: "<color>|<integer>";
initial-value: 0;
inherits: false;
}
@property --_p-is-color {
syntax: "<integer>";
initial-value: 1;
inherits: false;
}
/*
Below are various counter-styles used for
conditional output of various parts of the
`content` by matching certain values.
*/
/*
Not prefixed as exposed to be used
in `--known-ident` definition.
*/
@counter-style empty {
system: cyclic;
symbols: "";
}
/*
We can’t have a counter-style `none`,
so need to grind to it.
*/
@counter-style --_p-is-none {
system: cyclic;
symbols: "none";
/*
Here and below, we can use `range` to select
when to apply the value, otherwise the
counter-style from the `fallback` will be used.
*/
range: 1 1;
fallback: empty;
}
/* The rest of conditional counters. */
@counter-style --_p-unit-px {
system: cyclic;
symbols: "px";
range: 1 infinite;
fallback: empty;
}
@counter-style --_p-unit-deg {
system: cyclic;
symbols: "deg";
range: 1 infinite;
fallback: empty;
}
@counter-style --_p-unit-ms {
system: cyclic;
symbols: "ms";
range: 1 infinite;
fallback: empty;
}
@counter-style --_p-unit-perc {
system: cyclic;
symbols: "%";
range: 1 infinite;
fallback: empty;
}
@counter-style --_p-sign {
system: cyclic;
symbols: "-";
range: -1 -1;
fallback: empty;
}
@counter-style --_p-as-number {
system: cyclic;
symbols: "";
/*
The range for our “system” values,
for which we don’t show the number.
*/
range: 424242 424244;
}
@counter-style --_p-has-dot {
system: cyclic;
symbols: ".";
range: 1 infinite;
fallback: empty;
}
@counter-style --_p-leading-zero {
system: cyclic;
symbols: "";
range: 1 infinite;
}
@counter-style --_p-has-decimals {
system: cyclic;
symbols: "";
range: 0 0;
}
@counter-style --_p-is-unknown {
system: cyclic;
symbols: "<unknown>";
/*
The `424243` will come from the `initial-value`
of captured dimension.
*/
range: 424243 424243;
fallback: empty;
}
@counter-style --_p-quote {
system: cyclic;
symbols: "'";
range: 1 1;
fallback: empty;
}
@counter-style --_p-is-empty {
system: cyclic;
symbols: "<empty>";
range: 1 1;
fallback: empty;
}
@counter-style --_p-is-initial {
system: cyclic;
symbols: "<initial>";
range: 1 1;
fallback: empty;
}
@counter-style --_p-is-color {
system: cyclic;
symbols: "■";
range: 1 1;
fallback: empty;
}
}
After presenting the mixin’s API and a few use cases, I will go into some — but not all — details of how the mixin works. Decide for yourself if you want to first try and understand what is going on in its source code.
ixin’s API
Basic usage is trivial: after including the mixin’s code on your page, just adding a --preview
custom property on any element (or its ::before
or ::after
pseudo-element) will turn the mixin onGo to a sidenote:
<div style="--preview: 100vw"></div>
This mixin accepts many different types and calculations. The main limitations are: we can’t output the code for the colors, many unknown custom idents, or “complex” values — anything separated by spaces or commas, many functions, etc.
sing Extra Elements to Debug
The mixin allows showing up to two values per element, via its ::before
and ::after
pseudo-elements.
If you need more: the best way to do so would be to add more elements, and preview the values on them:
<div style="
--a: 2 + 2;
--b: sin(90deg);
--c: none;
--d: pi * e;
">
<ul>
<li style="--preview: var(--a);"></li>
<li style="--preview: var(--b);"></li>
<li style="--preview: var(--c);"></li>
<li style="--preview: var(--d);"></li>
</ul>
</div>
Here we can see how, when we have multiple custom properties we want to debug on some element, we can output them with extra elements. By default, custom properties will be inherited, so this will, generally, work. If they were not inherited (either via registering them with inherits: false
, or when overriding them on *
), we could always manually do so, like calling --a: inherit
on the direct child of our element, and repeating for more nested DOM.
hanging the Pseudo-Element
When set on the element itself, by defaultGo to a sidenote it will be output as :after
, but it is possible to control by using a --preview-position
cyclic toggle:
<div style="
--preview: 100vw;
--preview-position: var(--preview--before);
"></div>
Note that in the above example, we manually switched where the attr ()
is displayed, moving it from the ::before
to ::after
, while the position of the mixin’s output was determined only by --preview-position
custom property.
There are two other cyclic toggles that depend on this: var(--preview--inherited)
and var(--preview--self)
which are available only on the ::before
and ::after
and which allow us to do things differently, based on the --preview-position
value. There is an example of using them in the “Checking If Mixing Is On” section.
dding Idents
Out of the box, the mixin contains a very limited number of idents: none
, auto
, min-content
, and max-content
.
<ul>
<li style="--preview: none">none = </li>
<li style="--preview: auto">auto = </li>
<li style="--preview: min-content">min-content = </li>
<li style="--preview: max-content">max-content = </li>
<li style="--preview: hidden">hidden = </li>
</ul>
Anything else will be displayed as <unknown>
. There is currently no way to convert any unknown ident to a string in CSS — so we cannot do it just for anything. However, there is a CSSWG issue by Lea Verou about this, which could lead to an addition of the string()
function to CSS (or something similar), which will help us in the future (and will, actually, make many of the techniques from this article obsolete, haha).
So, if we can’t display unknown idents, how do we display those above? Custom counter styles! I will get into what they are and how it works in later sections, but thanks to them, the mixin has an API to define your own custom idents that would be added to this list.
Let’s say we want to add a visible
ident to be displayed with our mixin. Here is how:
@property --known-ident {
syntax: "empty|visible";
initial-value: empty;
inherits: false;
}
@counter-style visible {
system: cyclic;
symbols: "visible";
}
<p style="--preview: visible">visible = </p>
There are two things we need to do:
-
Override the definition of the
--known-ident
registered custom property, adding ourvisible
to itssyntax
. Very important: we need to keep theempty
value there, as it is required for things to work. -
Define a custom
@counter-style
with the name of our ident, and put it into itssymbols
as a string.
That’s it. If we were to define hidden
as well, our syntax
would look like "empty|visible|hidden"
, etc.
ashed Ident Bugs
Interestingly, if we try to add a dashed ident this way, today it will result in a bug in both Chrome and Firefox:
@property --known-ident {
syntax: "empty|visible|--some-dashed-ident";
initial-value: empty;
inherits: false;
}
@counter-style --some-dashed-ident {
system: cyclic;
symbols: "--some-dashed-ident";
}
<p style="--preview: --some-dashed-ident">--some-dashed-ident = </p>
If you open this example in Safari, it will work, though. This could actually be a bug not even in Chrome & Firefox, but in CSS Specs: Anders Hartvoll Ruud did open a PR to fix it based on my bug report. Sadly, there is currently no workaround for this.
hecking If The Mixin Is On
Every pseudo-element that is output via our mixin on this page has a red dotted outline. This is implemented without modifying the mixin itself, but on top of its code. It is possible thanks to the --preview--toggle
space toggle exposed by the mixin.
- When the mixin is off, this toggle has a value of
initial
. - When the mixin is on, it will be an “empty” value.
With this, we can add conditional styles for both states. Here is how I added the dotted outline:
@layer {
* {
--outline-value-inherited:
3px dotted var(--RED) var(--preview--toggle);
}
*::before,
*::after {
outline: var(--outline-value, revert-layer);
outline-offset: 1px;
--outline-value-self:
3px dotted var(--RED) var(--preview--toggle);
--outline-value:
var(--preview--inherited,
var(--outline-value-self,
var(--outline-value-inherited)))
var(--preview--self,
var(--outline-value-self))
;
}
}
There is a lot going on here aside from actually using the var(--preview--toggle)
:
-
Using an inherited custom property to be able to style the mixin when it is set both on the element itself, and on the pseudo-element.
-
Using the
--preview--inherited
and--preview--self
cyclic toggles to be able to listen to the--preview-position
and choose which value we need to use.
ombining With Existing Pseudos
Because we’re wrapping our mixin in a custom cascade layer, its content
(and counter-reset
) could be overridden by the properties that will have more priority in the cascade. In the next two sections, I outline two ways to work around this.
refix and Suffix
It is possible to add any content before or after the displayed “preview” via --preview-prefix
and --preview-suffix
custom properties. This will allow us to combine some content with the preview if we can move the content
.
They will be applied only if an element has a preview, so it is safe to set on *
in case you want this for all previews by default.
<div style="
--preview: 100vw;
--preview-prefix: '(';
--preview-suffix: ') ← ' attr(style);
"></div>
We can tell how these prefixes were added to the content of the pseudo-element via the mixin by the red outline that we mentioned in the previous section.
eusing Content and Reset
If we don’t want to remove the content
(to always display the previous content always, for example), it is possible to reuseGo to a sidenote the custom properties that --preview
provides with the counter-reset
and content
properties.
To do so, the mixin exposes two custom properties: --preview-content
and --preview-reset
, which we will need to add to our existing regular properties:
.with-after::after {
counter-reset:
--custom-counter 42
var(--preview-reset,)
;
--preview--toggle: inherit;
--when-with-preview:
", and a preview: "
var(--preview--toggle)
;
content:
"Some content! With a custom counter: "
counter(--custom-counter)
var(--when-with-preview,)
var(--preview-content,)
;
}
<div class="with-after" style="--preview: 100vw"></div>
<div class="with-after">No preview. </div>
There are a few things to note about the content
part:
-
We can use the abovementioned
--preview-toggle
to conditionally add something only when the preview is present. -
In this case, I did not set up things to work interchangeably when we set the
--preview
on the element itself or on the pseudo-element, so I did--preview--toggle: inherit
to get its value from the element itself. -
When we use the exposed values, we need not forget to add the comma for an empty fallback:
var(--preview-reset,)
andvar(--preview-content,)
, otherwise the IACVT value when the--preview
is not defined could break the properties.
Finally, both var(--preview-content,)
and var(--preview-reset,)
could be used as is in their respective properties, but with importance, when we know for sure that we want to override whatever the default value there, is in the stylesheet while debugging things. The last “inspector” use case is an example of this method.
se Cases
ebugging
Well, this whole article is about this use case… I guess another way to demonstrate it would be to show how we can debug itGo to a sidenote with itself:
.meta-test {
--foo: 42.024px;
--preview: var(--foo);
--preview-position: var(--preview--before);
&::after {
--preview: var(--meta-preview);
--preview-prefix: " (" var(--label) ": ";
--preview-suffix: ")";
}
}
<div
class="meta-test"
style="
--meta-preview: var(--_p-part1);
--label: 'before the dot'
"
></div>
<div
class="meta-test"
style="
--meta-preview: var(--_p-part2);
--label: 'after the dot'
"
></div>
In this example, we first change the position where the result of the --preview
is shown to be ::before
, and then set the --preview
to be the inherited value of our --meta-preview
variable on the ::after
, alongside the --label
to format it nicely.
isplaying Design Tokens
If you already have your design tokens as custom properties, then it might be pretty easy to displayGo to a sidenote them with this mixin.
Here are some of the colors I am using on this site:
Here are some space toggles, and variables containing 1
/0
values that I am setting for various conditional purposes, displaying their current state:
And the computed values of some dimensions (in the source some of them are using em
and rem
, but without string()
we can’t display the non-computedGo to a sidenote value today):
The HTML to display this is simple:
<ul class="design-tokens">
<li style="--preview: var(--LIGHT"></li>
<li style="--preview: var(--DARK"></li>
<!-- and so on -->
</ul>
And most of this example’s CSS is handling anything aside from the preview part that is only used inline:
.design-tokens {
display: grid;
gap: 0 1rem;
grid: "a b" / auto 1fr;
width: max-content;
margin: 0 auto;
}
.design-tokens > li {
display: grid;
grid: 1fr / subgrid;
grid-column-end: span 2;
padding: 0;
&::before {
all: initial;
font: inherit;
content: attr(style);
overflow: clip;
text-indent: -15ch;
white-space: pre;
font-size: var(--THEME_FONT_SIZE--SMALL);
font-family: var(--THEME_FONT_FAMILY_MONOSPACE);
}
&::after {
outline: none;
}
}
The most fun place here is probably using the overflow
and negative text-indent
to hide the first characters of the ::before
, as well as omitting the trailing )
in the HTML for the variables. Also — subgrid.
nspector
This is not a very polished example, but a fun one: we can implement something similar to the browser’s inspector. Click on the checkbox below and see it in action! And don’t forget to move your cursor!
I know, it is a bit junky, but it works! In this case, we’re inspecting the value of 1em
on the hovered element, but we can put anything in the --preview
.
Here is the codeGo to a sidenote behind it:
.has-inspector *:hover:not(:has(:hover)) {
anchor-name: --inspected;
outline: 2px solid hotpink;
&::after {
position: absolute;
position-anchor: --inspected;
left: anchor(inside);
top: anchor(outside);
z-index: 99;
padding: 0.5em;
color: #000;
background: pink;
--preview: 1em;
--preview-prefix: "1em = ";
pointer-events: none;
text-indent: 0;
counter-reset: var(--preview-reset) !important;
content: var(--preview-content) !important;
}
}
I am using anchor positioning here, but as a progressive enhancement: things should still work without it, just won’t be displayed as nicely.
he Implementation and Techniques
There is a lot going on inside! I learned a lot while working on this mixin, especially about how the custom counter styles work.
The core of the mixinGo to a sidenote is how we can use CSS counters to output strings. This is something I saw many people do already. One example that I vividly remember was from Lea Verou’s “CSS Variable Secrets” talk from CSS Day 2022 (which I attended), where she demonstrated how we can output a numeric value as a counter via counter-reset
. For integers, it was as simple as setting the CSS counter directly, and for floats, she presented a trick that used a registered custom property that she attributed to Ana Tudor.
However, our mixin is so much more complicated!
-
We want to output the value not just as an integer (rounding it from the float), but with actual digits after the dot.
-
These digits must be optional — if we have an integer, we don’t want to display the dot and the zeros after it.
-
The input could be not just a
<number>
, but any<length>
, or even other dimensions like<angle>
,<time>
, or<percentage>
. -
Even more: if we receive a value that has some unit, we want to display that unit as well.
-
All of the above is just for dimension-like values — but we’d want to display even more things like space toggle values, some idents, strings, and colors.
I won’t go into the details of every part of the implementation (I invite you to read the heavily commented source code of the mixin), but I will try to distill the techniques that I used, and things you could take away even if you’re not planning on using the mixin itself.
onditional Counters Output
The core technique that I use is the custom @counter-style
rule with ranges and fallbacks.
Many parts of our mixin are optional: units can be present or not, the dot and the digits afterward can be present — or not, various idents can also be sometimes present, or we could want to display the <empty>
, <initial>
, or <unknown>
strings.
We don’t have native conditions in CSS yet, so how can we handle all of this? The idea is to use many different counters, each with its own @counter-style
which defines how it should look, combining the final string from multiple parts. A good example is how we show the dot and the digits after it.
First, we define our counters as a part of the counter-reset
value, and then use them as a part of the content
’s value:
counter(--_p-sign, --_p-sign)
counter(--_p-part1, --_p-as-number)
counter(--_p-dot, --_p-has-dot)
counter(--_p-zero1, --_p-leading-zero)
counter(--_p-zero2, --_p-leading-zero)
counter(--_p-part2, --_p-has-decimals)
counter(--_p-part3, --_p-has-decimals)
As I said, I won’t go deep into the details, but the problem with the digits after the decimal separator is that we have to split our number into several parts to display it:
- The sign.
- The part before the dot.
- The optional dot itself.
- The first potential leading zero after the dot.
- The second potential zero.
- The three digits after the dot, separated into two parts.
Note that I am limiting the precision to be up to 3 digits — otherwise the implementation could get even more complex, with more parts.
Why do we have to have these extra leading zeroes? Because we need to separate our decimal into two parts. Imagine we have a value like 0.001
. A single counter can only be an integer, so if we split the value into two numbers, it will be 0
and 001
— and the 001
will be displayed just as a 1
, so we have to separately checkGo to a sidenote if we need to display these zeros. See the math for splitting and detecting the zeroes in the full source code, but the interesting part is the custom counter styles — the second argument of the counter()
function.
Here are a few of them that are used for the counters above:
@counter-style empty {
system: cyclic;
symbols: "";
}
@counter-style --_p-sign {
system: cyclic;
symbols: "-";
range: -1 -1;
fallback: empty;
}
@counter-style --_p-as-number {
system: cyclic;
symbols: "";
range: 424242 424244;
}
@counter-style --_p-has-dot {
system: cyclic;
symbols: ".";
range: 1 infinite;
fallback: empty;
}
@counter-style --_p-leading-zero {
system: cyclic;
symbols: "";
range: 1 infinite;
}
@counter-style --_p-has-decimals {
system: cyclic;
symbols: "";
range: 0 0;
}
There are four descriptors inside our @counter-styles
rules that we need to talk about.
yclic Counter System
You probably noticed that all our counters have the system: cyclic
there. There are manyGo to a sidenote different systems that can be used for counters, but in our case we only care about the cyclic
one, and we only use it with a single symbols
value.
With this, we can completely replace the value of a counter with our symbols
value.
ymbols: Empty or Not
In most cases, I am using this to omit displaying some counter, where we can use symbols: ““
— an empty string at certain conditions. And if we want to replace our counter with some string, we can provide the value to the symbols
, like for the dot — ”."
— or the minus sign — "-"
.
Range
Descriptor
he Conditions: The key for applying conditions is the range
descriptor. It allows defining a range for which our counter style applies. We pass two integers inside, with an optional infinite
keyword, and if the counter’s value is in this range, the style kicks in. Otherwise, the fallback
will be applied. Some examples:
-
The
range
for our--_p-sign
counter style is-1 -1
— as the range is inclusive, when the counter value is precisely-1
, it will output the-
value. We could make itinfinite -1
instead, but because we’re using the result of thesign()
function (or, actually, its workaround’s), the only values it will get are1
and-1
. -
Both the
--_p-has-dot
and--_p-leading-zero
have therange: 1 infinite
. This means that as long as they’re not zero or negative, they will output theirsymbols
value. -
The
--_p-has-decimals
has the range of0 0
and an absentfallback
, meaning that when it is not a zero, it will output the counter with the default decimal counter style. Otherwise, it will omit it. This is useful for omitting the trailing zeros for values like3.400
etc. -
The
range
for the--_p-as-number
is pretty special:424242 424244
— we use these very specific integers for other counters.
he Fallback
The only fallback
that we use is the empty
counter style that we also define. If the fallback is omitted, the default decimal
built-in counter style will be used.
etecting the Dimension-like Types
Another big aspect of how the mixin works is detecting and working with various <dimension>
-like types, and not only <number>
s. It is a multi-step process, not too complicated, but with many nuances.
apturing the Dimension-like Value
The first step that we need to do: is attempt to “capture” the value of the --preview
variable, casting it as a <dimension>
-likeGo to a sidenote type.
To do it, we first register a custom property:
@property --_p-captured {
syntax:
"<number>|<length>|<angle>|<time>|<percentage>";
initial-value: 424243;
inherits: false;
}
And then assign the --preview
to it, though via a calc()
:
--_p-captured: calc(var(--preview));
A few things to note here:
-
The
initial-value
here is something I mentioned in the “The Conditions:Range
Descriptor” section — it is used as a way to detect any non-parsed value as if the value couldn’t be cast as oursyntax
, we will get ourinitial-value
as the fallback. -
For all registered custom properties in the mixin, I am using
inherits: false
. This is not strictly necessary, as by defining the mixin as*, *::before, *::after
we break the inheritance anyway, but this way it is more explicit, but it could be interesting to know if there would be any performance difference between these options. -
When assigning the
--preview
, we wrap it withcalc()
— this allows us to preview incomplete calculated values. With custom properties, it is possible to pass around things like2 + 2
, but a sequence like this cannot be assigned to a property that expects a<dimension>
unless we wrap it with acalc()
.
tan(atan2())
xtracting Units: After we capture the value, we can attempt extracting the units from that value.
We need two things:
-
Get the value as a
<number>
, without its unit. -
Keep the information about which type the value was before we stripped the unit.
We can achieve both by using the “CSS Type Casting to Numeric: tan(atan2()) Scalars” technique by Jane Ori several times — once for each type, — and do so while assigning the resulting value to a number of registered custom properties:
@property --_p-from-number {
syntax: "<number>";
initial-value: 0;
inherits: false;
}
@property --_p-from-length {
syntax: "<number>";
initial-value: 0;
inherits: false;
}
@property --_p-from-angle {
syntax: "<number>";
initial-value: 0;
inherits: false;
}
@property --_p-from-time {
syntax: "<number>";
initial-value: 0;
inherits: false;
}
@property --_p-from-percentage {
syntax: "<number>";
initial-value: 0;
inherits: false;
}
They all have the sameGo to a sidenote syntax
and other descriptors, but this allows us to get multiple custom properties where the one that will receive some value will, well, have this value, and the rest will be just equal to 0
. Here is how we will assign all the values:
--_p-from-number: var(--_p-captured);
--_p-from-length: calc(
10000
*
tan(atan2(var(--_p-captured), 10000px))
);
--_p-from-angle: calc(
10000
*
tan(atan2(var(--_p-captured), 10000deg))
);
--_p-from-time: calc(
10000000
*
tan(atan2(var(--_p-captured), 10000s))
);
--_p-from-percentage: calc(
10000
*
tan(atan2(var(--_p-captured), 10000%))
);
Again, a few nuances worth mentioning:
-
The
--_p-from-number
is the simplest one: we just make sure it will be either a<number>
or0
, no need to do anything extra. -
For the rest, we’re using
tan(atan2())
method, where we are using our captured value as the first argument ofatan2()
, and then use a value with the desired unit we’re testing it against as the second argument. If both parts are correct and their types match, the calculation will be evaluated and captured. Otherwise, it will see a type mismatch and fall back to the property’s initial value —0
. -
You might ask why we have the
calc(10000 * …)
and use a high value as the second argument for theatan2()
— this is a workaround for a Firefox bug that I stumbled upon when testing the mixin. Without doing this, there might be a loss in precision when applyingtan(atan2())
. -
By default, when using registered properties with the type
<time>
, their value is assigned in seconds. However, I prefer to work withms
instead, so we had to adjust the calculation a bit.
erging Values Together
Now that we have that array of numbers, we can “merge” it:
--_p-value: (
var(--_p-from-length)
+
var(--_p-from-number)
+
var(--_p-from-angle)
+
var(--_p-from-time)
+
var(--_p-from-percentage)
);
Because all these custom properties are of different types, and have either some captured value or a 0
, we can just add all of them together and get the final value as a <number>
.
etting the Absolute Value & the Sign
Because of how we separate the number into two parts, I found it much more convenient to work with an absolute version of our value. Alongside it, we can extract the “sign”, which we will be using for a few things in the future.
We don’t have abs()
and sign()
in CSS yet in a cross-browser way (Chrome, hello?!), but it is not too difficult to get them through the available mathGo to a sidenote:
--_p-value-abs:
max(var(--_p-value), -1 * var(--_p-value));
--_p-sign:
calc(var(--_p-value) / var(--_p-value-abs));
andling Floats and Rounding Issues
I won’t go into the details of how I get all the variables necessary for displaying the part after the dot, but there are a few nuances I wanted to mention which can be seen in the first calculation where we get the <integer>
part of our value:
--_p-part1:
round(down, var(--_p-value-abs) + 0.0001, 1);
-
We need to round down, as that part should stay unchanged.
-
We have to add the
0.0001
— a workaround for a Firefox rounding bug. The bug is currently fixed, but I kept the workaround just in case. Note that this value should be smaller than the precision we’re working with: in my mixin, I am limiting the precision to 3 digits after the dot, so this value should bepow(10, -4)
. -
The
1
at the end ofround()
could be potentially omitted, but Safari did only recently implement the more recent change to the spec that allows to omit it for<number>
values, so it is better to keep it for better browser compatibility.
sing Counters For Units
This is a part where having several different custom properties per accepted <dimension>
-like type is very handy: we can have as many counters, each of which will have our custom empty
counter style when the value is a zero, and will output the desired unit otherwise.
First, we need to assign the counter values in the value for the counter-reset
:
--_p-unit-px round(
up,
var(--_p-from-length) * var(--_p-sign),
1
)
--_p-unit-deg round(
up,
var(--_p-from-angle) * var(--_p-sign),
1
)
--_p-unit-ms round(
up,
var(--_p-from-time) * var(--_p-sign),
1
)
--_p-unit-perc round(
up,
var(--_p-from-percentage) * var(--_p-sign),
1
)
We need to have an integer here, and for this, we have to round it up. Although, actually, the values in these properties can be negative! And, while we have a to-zero
keyword for round()
, we do not have a from-zero
alternative, for some reason. So, we have to first make the value absolute. And, because we already did this in the past and got a sign from it, we can do a simpler workaround compared to the max()
one, and just multiply each value by its sign!
Applying counters in the content
is trivialGo to a sidenote:
counter(--_p-unit-px, --_p-unit-px)
counter(--_p-unit-deg, --_p-unit-deg)
counter(--_p-unit-ms, --_p-unit-ms)
counter(--_p-unit-perc, --_p-unit-perc)
And the custom @counter-style
s for these are very straightforward:
@counter-style --_p-unit-px {
system: cyclic;
symbols: "px";
range: 1 infinite;
fallback: empty;
}
@counter-style --_p-unit-deg {
system: cyclic;
symbols: "deg";
range: 1 infinite;
fallback: empty;
}
@counter-style --_p-unit-ms {
system: cyclic;
symbols: "ms";
range: 1 infinite;
fallback: empty;
}
@counter-style --_p-unit-perc {
system: cyclic;
symbols: "%";
range: 1 infinite;
fallback: empty;
}
Having only positive numbers here is useful, as we can have just one range. Otherwise, we would have to use two of them:
range: infinite -1, 1 infinite;
etecting Space Toggles
Of course, I would want to be able to display the values of space and cyclic toggles! After all, this technique is only possible because of them.
This was a rather simple thing to do, but with its minor nuances. We need to first register two custom properties:
@property --_p-is-empty {
syntax: "<number>";
initial-value: 0;
inherits: false;
}
@property --_p-is-initial {
syntax: "<number>";
initial-value: 0;
inherits: false;
}
And then assign --preview
to them with a few bells and whistles:
--_p-is-empty: 1 var(--preview);
--_p-is-initial:
calc(1 - var(--_p-is-empty)) var(--preview,);
First, we can easily test if our value is empty by trying to assign the --preview
to it alongside a 1
value: if the value is empty, the result will be just a 1
, and will fall back to 0
otherwise.
Then, we do a similar thing with the custom property that tests for the initial
value: we use a var(--preview,)
, which will fall back to an empty value if the --preview
is IACVT, and will be some other value otherwise. Because it can also be empty, this could result in a false positive, but as we already know if the value is empty, we can use this and adjust the value accordingly.
etecting Strings
Curiously, before this step we did not encounter type grinding, which you could be familiar with from the “CSS-Only Type Grinding: Casting Tokens (sm|md|etc) into Useful Values” article by Jane Ori. Now, it is time!
First, registering custom properties that will be used for this:
@property --_p-is-string-or-0 {
syntax: "<string>+|<number>";
initial-value: 0;
inherits: false;
}
@property --_p-is-string {
syntax: "<number>";
initial-value: 1;
inherits: false;
}
And then assigning them:
--_p-is-string-or-0: var(--preview) "";
--_p-is-string:
var(--_p-has-strings, var(--_p-is-string-or-0))
var(--_p-no-strings, 0);
A few notes:
-
With counters, we can accept not just a single string, but multiple of them:
"<string>+"
. While we couldn’t display them too nicely, it is better than the alternative. -
We need to have a
<number>
in thesyntax
so we could fall back to0
when the value is not a string. But what if the--preview
would be itself a<number>
, like a0
? We can exclude it by assigning thevar(--preview) ""
— with an additional empty string! This will be valid if the other part is also a string (or multiple of them), but won’t be valid for any other combination. -
After the first step, if the value is a string, the result will be a string as well. Otherwise, it will be
0
. That’s where the second step comes into play, where we assign--_p-is-string
which has aninitial-value: 1
. It will accept the valid0
when the--preview
is not a string, and will fall back to1
when it is one! -
We need to use space toggles
--_p-has-strings
and--_p-no-strings
to properly handle the@property
with<string>
support, as it is only available in Safari starting from 18.2.
Now, we are not actually using this variable for counters — we’re not using them at all for displaying strings, as, well, content
already accepts them!
So the only thing we will need to do is register a custom property that will fall back to an empty string:
@property --_p-as-string {
syntax: "<string>+";
initial-value: "";
inherits: false;
}
Then assign it:
--_p-as-string: var(--preview);
And use while wrapping it with a space toggle, as otherwise it will break everything if the <string>
type is not supported:
counter(--_p-quote, --_p-quote)
var(--_p-has-strings, var(--_p-as-string))
counter(--_p-quote, --_p-quote)
Speaking of these space toggles, here is how we can detect if <string>
is supported:
body {
--_p-has-strings: ;
--_p-no-strings: initial;
}
@container style(--_p-as-string: "") {
body {
--_p-has-strings: initial;
--_p-no-strings: ;
}
}
By default, we set space toggles that say “No <string>
support”. Then, because the version of Safari that supports it also already have a container style queries support, we can use the already registered --_p-as-string
, and test if the value that will be present on the html
element is the one we did set via an @property
, and switch the space toggles if so.
The only problem with using container style queries for this: Firefox does not understand them yet, so even though it supports <string>
in @property
, it would display strings as <unknown>
. This is better than nothing working in older Safari, but I will explore other workarounds that won’t have this downside.
While we can just output the --_p-as-string
as is, we still need to have our custom counter-styles if we want to add quote symbols around our string. This is where our string detection will be handy, as we could use it for the counter value:
--_p-quote max(
0,
var(--_p-is-string) - var(--_p-is-empty)
)
Similar to how we did it when detecting space toggles, an empty value can result in a false-positive --_p-is-string
, so we need to subtract the --_p-is-empty
to accommodate for this (and use the max()
to only have 0
and 1
as possible values).
The @counter-style
is nothing special:
@counter-style --_p-quote {
system: cyclic;
symbols: "'";
range: 1 1;
fallback: empty;
}
etecting Idents
As I already mentioned in the “Adding Idents” section, for displaying idents we’re using counter styles… but how do we detect them? You might remember that we could register the --known-ident
custom property to provide additional idents, but how will those built-in work alongside it? It is a lot of “type grinding”:
--known-ident: var(--preview);
--_p-is-1-if-known: var(--known-ident);
--_p-is-known: var(--_p-is-1-if-known);
--_p-builtin-ident: var(--preview);
--_p-is-1-if-builtin: var(--_p-builtin-ident);
--_p-is-builtin: var(--_p-is-1-if-builtin);
--_p-is-custom-ident: var(--preview);
--_p-is-none-or-0: var(--_p-is-custom-ident);
--_p-is-none: var(--_p-is-none-or-0);
I won’t dig into how these work, but, essentially, we have to do three things:
-
Check if the value is a “known” ident — the one that we allow overriding as the public API of our mixin.
-
Check if the value is
none
— we have to handle this value separately, as it is not possible to have a@counter-style
with this name. -
Check if the value is one of the idents that we defined as built-in.
These are output in the content
this way:
counter(_, var(--known-ident))
counter(_, var(--_p-builtin-ident))
counter(--_p-is-none, --_p-is-none)
Only the none
value is using our pre-defined counter-style, and the known and built-in idents rely on the fact that we can use other idents as names for @counter-style
.
Fun fact: we don’t care which counters we’re using for the known and built-in idents, as we are not using the counter’s value in any way, so we can just use any counter name, as it will fall back to 0
if not defined anywhere.
etecting Colors
Sadly, currently, there is no way to get the value of a color as a string, but what we can do is apply the color to our pseudo-element. This is something that I will probably iterate more in the later versions of the mixin. I mostly want to work on how the color is actually displayed, but the way we detect it will stay the same.
Again a bit of type grinding:
--_p-is-color-or-0:
color-mix(in hsl, var(--preview) 100%, red 0%);
--_p-is-color: var(--_p-is-color-or-0);
Similar to a few other places, we can use a function — color-mix()
— that can only accept a color for the --preview
, and output 0
otherwise.
I will omit how the color is applied — as I mentioned, I will likely rework this place in the future.
etecting Numbers
Wait, didn’t we do this when we detected the dimension-like types? Not exactly! Remember all these cases where we had fallbacks to the “special” values like 424242
? Now that we detected all other types, we can check if our value is actually a number — or some other type.
First, we define a custom property that will use some other detected custom properties to exclude other types:
--_p-is-number: max(
0,
1
-
var(--_p-is-string)
-
var(--_p-is-empty)
-
var(--_p-is-initial)
);
We start from 1
, and then if we detect a string, empty, or IACVT, we get the 0
as the resultGo to a sidenote.
This value is then used for a bit more complex calculation of the first part of the number in the counter value, where we will also need to account for some other types — and also handle our “special” values like 424242
:
--_p-part1 calc(
var(--_p-part1) * var(--_p-is-number)
+
424242 * (1 - var(--_p-is-number))
+
var(--_p-is-builtin)
+
var(--_p-is-known)
+
var(--_p-is-none)
+
var(--_p-is-color)
)
Again, I won’t go into more detail about how it works: there is nothing special there.
he Bugs Roundup
When I compiled the result of My 2024 in CSS, I calculated that, on average, I reported a bit more than 2 bugs per month for the past two years.
I started working on this mixin — and its article — at the end of December, right after finishing my “Indirect Cyclic Conditions: Prototyping Parametrized CSS Mixins” article. I ended up finding and reporting a few bugs related to what I was doing:
-
“
tan(atan2())
results in incorrect values compared to Chrome and Safari” (Firefox) -
“Counter is not reset when using a registered custom property” (Firefox) — this one ended up being a duplicate of the previous one.
-
“Dashed idents are not allowed in the syntax of registered custom properties” (Firefox)
-
“Dashed idents are not allowed in the syntax of registered custom properties” (Chrome)
-
“An out-of-flow element in phrasing content introduces a soft-wrap opportunity and breaks browser search” (Chrome) — not my report, but I found it when trying to report the issue I encountered. This issue was not active for 5 years, so I added a comment with my test case.
This shows how exploring these complex use cases helps to identify various issues.
inal Words
That was a lot! Originally, I wanted to just try my new technique and create a simple mixin… Create I did, but fell in a rather deep rabbit hole, and now invited you inside as well.
Please try this mixin the next time you do some experiments involving complex calculations, and let me know if it was of any help.
And even if not, I hope you will retain some of the small techniques I showcased in this article, like how the @counter-style
works, or the ways we can understand the types of some dimensions.
As it often goes with my experimental articles: by proving that something is possible in a hacky way, I hope to prove authors’ interest and implementation possibility, and unlock further experiments based on my results. If you think all of this is overcomplicated — it is! So let’s hope — and encourage browser developers — that something like this will be possible natively in the future, with as simple CSS as just wrapping your values in string()
.
Published on with tags: #CSS Variables #CSS Counters #CSS Layers #Experiment #Practical #CSS