Recursive AI

Style HTML form controls and dark mode in C#

All onerender recipes

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.

Terminal
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:

C#
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;
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 = r.X + r.Width * 0.8, Y = y, Buttons = MouseButtons.Primary, Time = 48 });
ui.HandlePointer(new PointerInput { Action = PointerAction.Up, X = r.X + r.Width * 0.8, 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.