# Colour swatches beside inline hex codes in Astro — why a rehype plugin beat a Swatch component

> A rehype plugin for Astro MDX that draws a colour swatch before every inline-code hex colour, chosen over a Swatch component so existing articles need no edits.

- Source: https://oharu121.com/blog/astro-rehype-plugin-hex-color-swatch-inline-code-mdx/
- Published: 2026-10-31T22:57:23+09:00
- Tags: Astro, CSS, MDX, rehype, CSP, Expressive Code

---
## Introduction

I was rereading [my logo article](/blog/ai-agent-logo-design-fonttools-svg-path-bugs-jetbrains-mono/), where one paragraph argues that the site accent `#1d4ed8` and the seal colour `#d8452a` clash, and realised it asks the reader to compare two colours they cannot see. VS Code handles the same problem in CSS and similar files by drawing a small square of the colour in front of every hex literal.

**I did the same on this blog with a rehype plugin.** Every hex code in every existing article, in all three languages, now has a swatch in front of it, so a reader sees at a glance which colour it names.

## A swatch inside every inline hex code

VS Code calls the feature color preview, and the `editor.colorDecorators` setting switches it on and off. This is what it looks like on this site's own figure tokens:

*Image: VS Code showing a small coloured square before each hex value in a list of light-dark() custom properties*

*VS Code's color preview on the `--fig-*` tokens in `src/styles/global.css`.*

The blog's version is `rehypeColorSwatch`, in `src/lib/rehype-color-swatch.ts`. Rehype is the HTML stage of unified, the syntax-tree toolkit Astro uses to turn Markdown into pages, and a rehype plugin is a function that receives the page as hast, an HTML syntax tree, and edits it in place.

*Figure — Pipeline: The stages unified runs between an article's source and its page. The swatch plugin edits the HTML tree, beside the two plugins this blog already had.*

**For every inline `<code>` whose whole text is a hex colour, the plugin inserts an empty `<span>` in front of the text** and hands the colour to CSS as a custom property. The built HTML for `#1d4ed8` is:

```html title="Built HTML for one inline hex code"
<code>
  <span class="color-swatch" aria-hidden="true" style="--swatch:#1d4ed8"></span>
  #1d4ed8
</code>
```

The span holds no text and carries `aria-hidden`, so copying the code still copies exactly `#1d4ed8`, and a screen reader reads only the hex. One CSS rule draws the square, sized in `em` so it scales with the code's font like a glyph:

```css title="src/styles/global.css"
.color-swatch {
  display: inline-block;
  width: 0.8em;
  height: 0.8em;
  margin-inline-end: 0.35em;
  vertical-align: -0.05em;
  background: var(--swatch);
  border: 1px solid var(--text-3);
  border-radius: 2px;
}
```

Registering the plugin takes one import and one entry in the Markdown processor's plugin array:

```js title="astro.config.mjs" ins={1,4}
import { rehypeColorSwatch } from "./src/lib/rehype-color-swatch.ts";
// …
markdown: {
  processor: unified({ rehypePlugins: [rehypeTableScroll, rehypeLinkIcons, rehypeColorSwatch] }),
},
```

`@astrojs/mdx` inherits this Markdown configuration unless `extendMarkdownConfig: false` turns that off, so the one entry covers every `.mdx` article. The Japanese and Traditional Chinese files carry the same backticks as the English, and get the same swatches.

## Why a rehype plugin and not a `<Swatch>` component

**A component draws a swatch only where an author types one, while the plugin draws one wherever a hex colour already sits in backticks**, which is how this blog has always written them. So adding the plugin was the only change, and every published article got its swatches unedited.

| | rehype plugin | `<Swatch>` component |
| --- | --- | --- |
| What the author writes | `#1d4ed8`, as before | `<Swatch color="#1d4ed8" />`, plus an import |
| Existing articles | Swatches on the next build | Every occurrence rewritten by hand |
| Japanese and Chinese files | Same backticks, same swatches | Every occurrence rewritten in each file |
| A hex typed in plain backticks later | Gets a swatch | Renders as plain code |

With a component, plain backticks keep rendering as plain code, so a swatch in any future article depends on its author remembering the component. An AI agent drafting an article would have to be told to use the component instead of backticks too, and even a written rule is not always followed.

## Where the plugin looks in the tree

**The plugin walks every element except `<pre>`, tests the text of each `<code>` it reaches, and also walks into MDX components.**

*Figure — TreeWalk: Which nodes of an article's tree the plugin enters, skips, and tests.*

1. **Inline code only.** The walk never enters a `<pre>`. Expressive Code, the plugin that renders this site's fenced code blocks, owns everything inside them, so a hex in a code block stays exactly as written.
2. **Whole text only.** A `<code>` qualifies when its only child is a text node matching a hex colour. `color: #fff` gets no swatch. Inside a longer snippet **the plugin cannot tell a colour from a lookalike such as the CSS id selector `#fade`**, because unlike VS Code it does not parse the snippet's language.
3. **Into MDX components.** A component such as `<Callout>` is not an `element` node in the tree, and the walk has to step into it by name.

The Markdown inside a component is still ordinary hast, but a walk that descends only into `element` nodes stops at the component's edge, so **a hex code inside a callout gets no swatch while the same code in a paragraph does**.

> **HEADS UP**
>
> **This callout is an mdxJsxFlowElement**
>
> MDX does not turn a component into an `element` node. It uses one of two node types of its own:
>
> 1. `mdxJsxFlowElement`: a component written on its own line, like this callout.
> 2. `mdxJsxTextElement`: a component written inside a sentence, like `text <Badge /> text`.
>
> The swatch beside `#059669` is drawn because the walk steps into this node.

The walk is a hand-written recursion. The highlighted branch is the one that steps into components:

```ts title="src/lib/rehype-color-swatch.ts" {6-9}
// 3- and 4-digit forms need a letter, so `#447` stays an issue number
const HEX = /^#(?:[0-9a-f]{6}|[0-9a-f]{8}|(?=[0-9]*[a-f])[0-9a-f]{3,4})$/i;

function addSwatches(node: HastNode): void {
  for (const child of node.children ?? []) {
    if (child.type === "mdxJsxFlowElement" || child.type === "mdxJsxTextElement") {
      addSwatches(child);
      continue;
    }
    if (child.type !== "element") continue;
    if (child.tagName === "pre") continue;

    if (child.tagName === "code") {
      const color = colorOf(child);
      if (color) {
        (child.children ??= []).unshift({ /* builds the class="color-swatch" span node, code omitted */ });
      }
      continue;
    }

    addSwatches(child);
  }
}
```

## A border that stays visible on dark and light backgrounds

This blog outlines its code chips with `--border`, the token it uses for hairlines. The swatch sits inside the code chip, whose background is within 1.06:1 contrast of a `#0b1120` swatch in dark mode and 1.05:1 of a `#ffffff` swatch in light mode.

For those colours the fill draws nothing and only the border is visible, and a `--border` outline reaches only 1.28:1 and 1.23:1 against the same swatches.

So I used `--text-3`, the muted grey this blog uses for secondary text, for the swatch's border instead. **A colour close to the page background still shows as an outlined square.** The contrast of each token's outline against those two swatches:

| Border token | Against `#0b1120`, dark | Against `#ffffff`, light |
| --- | --- | --- |
| `--border` | 1.28:1 | 1.23:1 |
| `--text-3` | 3.96:1 | 4.76:1 |

WCAG 2.2, the W3C's web accessibility standard, asks for 3:1 contrast on graphical objects a reader needs to see. **`--text-3` clears it in both themes; `--border` reaches it in neither.**

*Image: Two inline code chips, #0b1120 and #ffffff, on a dark page and on a light page, each swatch outlined with the faint hairline colour*

*With `--border`, the swatch for the page's own background colour all but disappears.*

*Image: The same two inline code chips on dark and light pages, each swatch outlined with a mid-grey border*

*With `--text-3`, both swatches read as squares in both themes.*

## The colour travels in a style attribute

An article can use any hex colour, and I cannot prepare a class for every one. **So the plugin writes the colour into `style="--swatch: #0b1120"`**, and the CSS rule reads `#0b1120` back out with `background: var(--swatch)` and applies it.

An inline `style` attribute is governed by the page's Content Security Policy (CSP), a response header that tells the browser what the page may load and run. This site sends `style-src 'self'; style-src-attr 'unsafe-inline'`.

> **HEADS UP**
>
> **What each directive covers**
>
> - `style-src 'self'`: stylesheet files, loaded only from this blog and never from a third-party URL.
> - `style-src-attr 'unsafe-inline'`: only `style="…"` attributes on elements, and it allows them.
>
> The swatch's colour lives in the `style` attribute on its `<span>`, so the swatch depends on `style-src-attr 'unsafe-inline'`.

`style-src-attr 'unsafe-inline'` was already in the policy for the colours that Shiki, the highlighter inside Expressive Code, writes onto highlighted code. I covered that decision in [my CSP article](/blog/astro-vercel-security-csp-service-worker-hash/). **The plugin needed no change to the policy.**

On a site with no `style-src-attr` and no `'unsafe-inline'` in `style-src`, the browser blocks every inline `style` attribute, and every swatch renders as an empty outlined square.

## Summary

Hex colours in inline code on this blog now render with a swatch of the colour in front of them, drawn by a rehype plugin rather than a component, so every article picked it up from the backticks it already had. The steps, in short:

1. Register it in `markdown.processor`, which `@astrojs/mdx` inherits.
2. Match inline `<code>` whose whole text is the colour.
3. Skip `<pre>`, and walk into `mdxJsxFlowElement` and `mdxJsxTextElement`.
4. Give the swatch a border token that reaches 3:1 against both the dark and the light page background.
5. On a site with a CSP, passing the colour as a custom property in `style` needs `style-src-attr` to allow inline attributes.

## References

- [hast, the HTML syntax tree format that rehype plugins edit](https://github.com/syntax-tree/hast)
- [mdast-util-mdx-jsx, which defines the mdxJsxFlowElement and mdxJsxTextElement node types](https://github.com/syntax-tree/mdast-util-mdx-jsx)
- [MDN: the CSP style-src-attr directive and its fallback to style-src](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/style-src-attr)
- [WCAG 2.2 Understanding SC 1.4.11: Non-text Contrast](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html)
- [VS Code CSS documentation: syntax coloring and color preview, including editor.colorDecorators](https://code.visualstudio.com/docs/languages/css#_syntax-coloring-color-preview)
