---
title: "Style HTML form controls and dark mode in C#"
url: https://recursiveai.org/onerender/cookbook/form-controls-dark-mode/
description: "Render HTML form controls in C# in light or dark mode, restyle a custom switch through :checked as it is clicked, and style a range slider. Runnable code with OneRender."
version: "1.3.1"
---

# Style HTML form controls and dark mode in C#

Set the color scheme when you render, and style controls with ordinary CSS: OneRender draws native-looking controls in the light or dark scheme, applies `:checked` as a switch is clicked, and lets you replace a range slider’s track and thumb.

```shell
dotnet new console -o FormControls && cd FormControls
dotnet add package RecursiveAI.OneRender.Image --version 1.3.1
```

Put this in `Program.cs` and run `dotnet run`:

```csharp
using OneRender;
using OneRender.Image;
using OneRender.Interaction;
using OneRender.Layout;
using OneRender.Painting;
using RecursiveAI.OneHtml;

string html = """
<!doctype html>
<html lang="en">
<style>
  :root { color-scheme: light dark; accent-color: #16a34a }
  body { margin: 0; padding: 16px; font: 15px sans-serif }
  label { display: flex; align-items: center; gap: 8px; margin: 0 0 12px }
  .switch { appearance: none; margin: 0; width: 36px; height: 20px;
            border-radius: 10px; background: #9ca3af }
  .switch:checked { background: #16a34a }
  .switch:checked + span { font-weight: bold }
  input[type=range] { appearance: none; width: 200px }
  input[type=range]::-webkit-slider-runnable-track {
    height: 6px; border-radius: 3px; background: light-dark(#d1d5db, #4b5563) }
  input[type=range]::-webkit-slider-thumb {
    appearance: none; width: 18px; height: 18px; margin-top: -6px;
    border-radius: 50%; background: #16a34a }
</style>
<label><input type="checkbox" class="switch" id="sync"><span>Sync</span></label>
<label><input type="checkbox" checked> Newsletter</label>
<label>Volume <input type="range" id="volume" min="0" max="10" value="4"></label>
<input type="text" placeholder="Search">
</html>
""";

// The host says which scheme the user prefers; `color-scheme: light dark` follows it.
foreach (bool dark in new[] { false, true })
{
    RgbaImage shot = HtmlRender.ToImage(html,
        new HtmlRenderOptions
        { Width = 320, Height = 180, PrefersDarkColorScheme = dark });
    File.WriteAllBytes(dark ? "dark.png" : "light.png", shot.ToPng());
    Console.WriteLine($"{(dark ? "dark " : "light")}: canvas {Hex(shot, 300, 170)}");
}

// Live: clicking the switch restyles it through `:checked`,
// and a drag moves the styled thumb.
InteractivePage ui = InteractivePage.Create(html,
    new HtmlPageOptions(FontProviders.CreateDefault())
    { ViewportWidth = 320, ViewportHeight = 180, PrefersDarkColorScheme = true });
Node sync = ui.FindElement("sync");
Node volume = ui.FindElement("volume");
ui.Click(sync, time: 16);
Rect r = ui.Layout.GetBoundingClientRect(volume);
double y = r.Y + r.Height / 2;
double end = r.X + r.Width * 0.8;
ui.HandlePointer(new PointerInput { Action = PointerAction.Down, X = r.X + 20, Y = y,
    Button = 0, Buttons = MouseButtons.Primary, Time = 32 });
ui.HandlePointer(new PointerInput { Action = PointerAction.Move, X = end, Y = y,
    Buttons = MouseButtons.Primary, Time = 48 });
ui.HandlePointer(new PointerInput { Action = PointerAction.Up, X = end, Y = y,
    Button = 0, Time = 64 });
Console.WriteLine($"sync checked: {ui.IsChecked(sync)}, "
    + $"volume: {ui.GetRangeValue(volume)}");

var list = new DisplayList();
ui.Page.Paint(list);
RgbaImage after = DisplayListRasterizer.Render(list, 320, 180);
File.WriteAllBytes("after.png", after.ToPng());
Rect s = ui.Layout.GetBoundingClientRect(sync);
Console.WriteLine($"switch: {Hex(after, (int)(s.X + 4), (int)(s.Y + s.Height / 2))}");

static string Hex(RgbaImage img, int x, int y)
{
    int i = (y * img.Width + x) * 4;
    return $"#{img.Pixels[i]:x2}{img.Pixels[i + 1]:x2}{img.Pixels[i + 2]:x2}";
}
```

```text
light: canvas #ffffff
dark : canvas #121212
sync checked: True, volume: 8
switch: #16a34a
```

`light.png` and `dark.png` are the same page under each scheme: white or `#121212` canvas, the field, checkbox and text in Chrome’s light or dark palette, and the slider track from `light-dark()`. In `after.png` the switch is green, Sync is bold through `:checked + span`, and the thumb sits at 8.

## How it works

- **The page opts in, the host decides.** `color-scheme: light dark` uses dark when `PrefersDarkColorScheme` is set on `HtmlRenderOptions` or `HtmlPageOptions`, which also drives `@media (prefers-color-scheme: dark)`. A page without `color-scheme` stays light. System colors (`Canvas`, `CanvasText`, `Field`, `ButtonFace`, `GrayText` …) and `light-dark()` resolve under the element’s scheme.
- **Native controls follow Chrome.** Fields, buttons, selects, checkboxes, radios, range, progress and meter use Chrome’s light or dark look. `accent-color` sets the fill, and the check mark turns white or dark by contrast, as in Chrome. `::placeholder` sets the placeholder color, and `::selection` the selection highlight in a text field.
- **Form-state selectors stay live.** `:checked`, `:indeterminate`, `:disabled`, `:enabled`, `:read-only` and `:read-write`, with `:checked + label` and `:has(:checked)`, restyle after a click, a key, `SetChecked`, `SetIndeterminate` or a form reset, so `appearance: none` checkboxes and radios can be styled entirely in CSS. The restyle allocates nothing once warm.
- **A range is styled through its parts.** `::-webkit-slider-runnable-track` and `::-webkit-slider-thumb` (and the `-moz-` names) set size, background, border, radius, shadow and margin, including the `margin-top` centering trick. `appearance: none` switches from the native look to your boxes, and a drag follows the thumb as laid out. `GetRangeValue` and `SetRangeValue` read and set the value.

Every color here was measured from Chrome 156; the tables are in [Forms and text editing](https://recursiveai.org/onerender/docs/forms/#themed-controls-color-scheme-accent-color).
