CoreMesh

Dialog Element Text Color Invisible? The CanvasText Fix

Text in your HTML dialog is invisible or the wrong color? The browser default sets color: CanvasText. Here is the one-line CSS fix and how to check it.

Dialog Element Text Color Invisible? The CanvasText Fix

If the text inside your <dialog> is invisible, washed out, or just the wrong color, the cause is almost always the browser's default stylesheet. Every major browser ships a rule that sets color: CanvasText and background-color: Canvas on dialog. Because color is set directly on the element, the dialog ignores the text color you put on body or :root.

The fix is one rule in your own CSS:

css
dialog {
  color: var(--text);
  background-color: var(--surface);
}

Swap in whatever custom properties or literal colors your site uses. That's exactly what fixed it for me: the text in my modal was nearly invisible, and a single dialog { color: var(--text) } rule brought it back. The rest of this page covers why the default behaves this way, how to confirm it in DevTools, and which variant of the fix suits your setup.

Why dialog text color doesn't inherit from body#

The color property inherits. Set it once on body and every paragraph, list and span picks it up without extra rules. That's why most people never write a color rule for individual elements, and why a dialog that ignores it feels like a bug.

It isn't a bug. The HTML standard's rendering section gives dialog its own default styles, and browsers follow it closely. The relevant part looks like this:

css
dialog {
  position: absolute;
  inset-inline-start: 0;
  inset-inline-end: 0;
  width: fit-content;
  height: fit-content;
  margin: auto;
  border: solid;
  padding: 1em;
  background-color: Canvas;
  color: CanvasText;
}

An explicit color declaration on an element beats inheritance every time, even when it comes from the user agent stylesheet. Your body { color: #e6e6eb } never reaches the dialog's text because the dialog already has a value of its own.

Canvas and CanvasText are system colors defined in the CSS Color Module Level 4 specification. They stand for "the default document background" and "the default document text". What they resolve to depends on the element's color scheme. With the default color-scheme: normal, which behaves as light, Canvas is white and CanvasText is black in practice. With color-scheme: dark, the pair flips to a dark background and light text. MDN's system color reference lists the full set.

That scheme dependency is where the trouble starts. A few common ways it goes wrong:

  • Dark theme built from custom properties, no color-scheme declared. The browser thinks the page is light. The dialog gets a white background and black text, which is readable but clashes with everything around it. Then you, a reset, or a component style set the dialog background to your dark surface and forget the text. Now it's black on near-black.
  • color-scheme: dark is declared, but the dialog background is a light custom value. CanvasText becomes near-white, your background is near-white, and the text vanishes.
  • A design system that themes body only. The modal was never in scope, and nobody notices until a dark-mode user opens it.

The same default applies to elements with the popover attribute. The user agent stylesheet gives [popover] the same color: CanvasText and background-color: Canvas pair, so if you're styling both, treat them together.

How to confirm it's CanvasText in DevTools#

Don't guess. The check takes under a minute in any Chromium browser or Firefox:

  1. Open the dialog on the page, with showModal() or show().
  2. Right-click the dialog text and choose Inspect.
  3. Click the dialog element in the Elements tree, then look at the Styles pane.
  4. Scroll until you see a block labeled "user agent stylesheet". You'll find color: CanvasText and background-color: Canvas there.
  5. Switch to the Computed tab to see the resolved rgb() value.

If you'd rather do it from the console:

javascript
const d = document.querySelector('dialog');
console.log(getComputedStyle(d).color, getComputedStyle(d).backgroundColor);

If the computed text color is rgb(0, 0, 0) while your page text is light, or rgb(255, 255, 255) while your page is light, you've found it. Note that the dialog lives in the top layer when it's modal, but the top layer doesn't change inheritance. The dialog still inherits from its DOM parent, usually body. The only reason it didn't pick up your color is the explicit default on the element itself.

Three ways to fix the dialog element text color#

All three work. Pick based on how much of your theme you want the browser to handle for you.

Set color and background on dialog directly#

This is the most predictable option and the one I used. Put the dialog on the same custom properties as the rest of your theme:

css
:root {
  --bg: #1e1e24;
  --surface: #2a2a33;
  --text: #e6e6eb;
}

body {
  background: var(--bg);
  color: var(--text);
}

dialog {
  background-color: var(--surface);
  color: var(--text);
  border: 1px solid #44444f;
  border-radius: 8px;
  padding: 1.5rem;
}

dialog::backdrop {
  background: rgb(0 0 0 / 0.5);
}

If your theme switches by toggling the custom properties on :root, the dialog follows automatically because it reads the same variables. A minimal page to test it:

html
<button id="open">Open dialog</button>

<dialog id="box">
  <p>This text uses the theme color, not CanvasText.</p>
  <form method="dialog">
    <button>Close</button>
  </form>
</dialog>

<script>
  document.getElementById('open').addEventListener('click', () => {
    document.getElementById('box').showModal();
  });
</script>

Declare color-scheme so system colors match your theme#

If your dark palette is close to the browser's own dark defaults, you can leave the system colors in place and tell the browser which scheme the page uses:

css
:root {
  color-scheme: dark;
}

Or, for a site that follows the user's OS setting:

css
:root {
  color-scheme: light dark;
}

Now Canvas and CanvasText resolve to dark-scheme values and the dialog text becomes light on dark without any dialog-specific rule. As a bonus, native form controls, scrollbars and the default backdrop adapt too. MDN's color-scheme page covers the details and support.

The limitation: you get the browser's idea of dark, not yours. If your surfaces are a specific tinted gray and your text is slightly off-white, the dialog will look close but not identical to the rest of the page. For a branded UI, combine this with the first option.

Use inherit to piggyback on body#

If you'd rather not repeat the values, you can tell the dialog to take whatever its parent has:

css
dialog {
  color: inherit;
  background-color: inherit;
}

This works when the dialog is a direct child of body and body carries the theme colors. It's less robust than the first option because moving the dialog inside a differently colored container changes what it inherits. Fine for small sites, risky in a component library.

Check the contrast, not just visibility#

Fixing the color gets the text back on screen. It doesn't guarantee the text is readable enough. Once you've chosen a text and surface color for the dialog, run the pair through a contrast check. The script below computes the WCAG contrast ratio for a few pairs, including the kind of near-invisible combination the CanvasText default produces on a dark surface:

python
def luminance(hex_color):
    hex_color = hex_color.lstrip("#")
    channels = []
    for i in (0, 2, 4):
        c = int(hex_color[i:i + 2], 16) / 255
        channels.append(c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4)
    r, g, b = channels
    return 0.2126 * r + 0.7152 * g + 0.0722 * b


def contrast(fg, bg):
    hi, lo = sorted((luminance(fg), luminance(bg)), reverse=True)
    return (hi + 0.05) / (lo + 0.05)


pairs = {
    "black (light-scheme CanvasText) on dark surface": ("#000000", "#2a2a33"),
    "theme text on dark surface": ("#e6e6eb", "#2a2a33"),
    "white (dark-scheme CanvasText) on light surface": ("#ffffff", "#f4f4f6"),
    "dark text on light surface": ("#1e1e24", "#f4f4f6"),
}

for label, (fg, bg) in pairs.items():
    print(f"{label}: {contrast(fg, bg):.2f}:1")

WCAG AA asks for 4.5:1 on normal body text and 3:1 on large text. The rules and the math are covered in the CoreMesh article on WCAG color contrast ratio rules and how to check them, and if you want a tool rather than a script, the post on passing WCAG AA and AAA with a contrast checker walks through that.

Don't forget the buttons and links inside the dialog. If they rely on currentColor or on a color set on body, they're affected by the same default, and the fix on dialog covers them. If they have their own explicit colors, check those against the dialog surface separately.

What went wrong for me and what I'd do differently#

The symptom was simple: the text in my <dialog> modal was nearly invisible. The cause turned out to be the browser default stylesheet setting color: CanvasText on dialog, which stopped the element from inheriting the text color used everywhere else on the page. One rule, dialog { color: var(--text) }, fixed it.

What I'd change going forward is where that rule lives. The dialog should be part of the base theme from day one, right next to body, not something patched after someone opens a modal and sees nothing. Concretely:

  • Put dialog and [popover] in the same block that sets body colors, so any theme change hits all three.
  • Declare color-scheme on :root even when every color is custom. It costs nothing and makes the browser's defaults for controls and system colors agree with the page.
  • Open every dialog in both light and dark modes before shipping. The default only looks fine in whichever scheme happens to match your palette.
  • Check the chosen text and surface pair against WCAG AA, not just "can I see it".

The MDN page for the dialog element is a good reference for the rest of its behavior, including showModal(), the ::backdrop pseudo-element and the close event.

Checklist for dialog text color#

  • Inspect the open dialog and confirm color: CanvasText in the user agent stylesheet block.
  • Add dialog { color: ...; background-color: ...; } using the same custom properties as body.
  • Apply the same rule to [popover] if you use the Popover API.
  • Declare color-scheme: dark or color-scheme: light dark on :root so system colors match your theme.
  • Style dialog::backdrop explicitly rather than relying on the default.
  • Run the final text and surface colors through a contrast check and aim for at least 4.5:1.
  • Open the dialog in both color schemes and in at least two browsers.

Quick answers

Why is the text in my HTML dialog invisible?

The browser's default stylesheet sets color: CanvasText and background-color: Canvas on dialog, so it ignores the text color inherited from body. If your own CSS changes the dialog background but not its color, the default text color can end up nearly the same shade as the background.

How do I change the dialog element text color?

Add a rule like dialog { color: var(--text); background-color: var(--surface); } using the same values as the rest of your theme. An explicit color on dialog overrides the user agent default.

Does color-scheme fix dialog text color in dark mode?

Yes, declaring color-scheme: dark or color-scheme: light dark on :root makes Canvas and CanvasText resolve to dark-scheme values, so the default dialog becomes light text on a dark background. For exact brand colors, still set color and background on dialog directly.

Does the same problem affect popover elements?

Yes. Elements with the popover attribute get the same color: CanvasText and background-color: Canvas defaults, so style dialog and [popover] together.

Did this solve it?

One tap. No account.

Saved you an hour? Send a tip.

USDC or ETH on Base, Ethereum, Arbitrum and more. Keeps the notes free and the examples running.

Base

Network

0xc783bc5c3fa14cEC3f09A22284bC91C5AF0d59e1
Open in wallet · 5 USDC

On a computer? Scan the code with your phone wallet, or copy the address.

Pick the network your wallet or exchange uses. USDC works on all six; ETH on Base, Ethereum, Arbitrum and Optimism. Don't send on Tron or Solana: this address doesn't exist there. Tips are optional and unlock nothing; every guide stays free.

Discussion

Start the discussion

Indra Wijaya

Builds and runs AI agents, bots and content pipelines, and writes down what works and what broke.

About · Email · RSS

Keep reading

New notes, no spam.

One post at a time, only when there's something worth reading. Follow in any feed reader.

Subscribe via RSS