Keyboard shortcuts

Press ← or → to navigate between chapters

Press ? to show this help

Press Esc to hide this help

Theming

Rinch’s theme system is CSS-variable-based, Mantine-inspired, and designed to be extended rather than fought against. Enable it with the theme feature (auto-enabled by components):

rinch = { git = "...", features = ["desktop", "theme"] }

Quick Start

fn main() {
    let theme = ThemeProviderProps {
        primary_color: Some("cyan".into()),
        default_radius: Some("md".into()),
        dark_mode: false,
        ..Default::default()
    };
    App::new(app)
        .title("My App")
        .size(800, 600)
        .theme(theme)
        .run();
}

That’s it. Every component picks up your colors, radius, and spacing through CSS variables. Toggle dark_mode: true and the whole UI flips. All semantic colors — body background, text, borders, placeholders — adjust automatically.

Omit .theme(...) to get the default theme (blue primary, light mode, small radius) — it is loaded either way, which is what makes components visible out of the box.

ThemeProviderProps

PropTypeDefaultDescription
primary_colorOption<String>"blue"Primary color name
primary_shadeOption<u8>6Shade index (0-9)
default_radiusOption<String>"sm"Border radius (xs, sm, md, lg, xl)
font_familyOption<String>System fontsPrimary font stack
font_family_monospaceOption<String>System monoMonospace font stack
dark_modeboolfalseDark mode

CSS Variables

The theme generates CSS custom properties you can use anywhere in your styles.

One variable in this list — --rinch-window-top-inset — is not generated by the theme. It is published at runtime by whatever window chrome rinch is drawing, and is documented here because you consume it the same way as the rest.

Colors

14 named palettes, each with 10 shades (0 = lightest, 9 = darkest):

var(--rinch-color-blue-0)     /* Lightest blue */
var(--rinch-color-blue-5)     /* Mid blue */
var(--rinch-color-blue-9)     /* Darkest blue */

var(--rinch-primary-color)    /* Primary at your chosen shade */
var(--rinch-primary-color-0)  /* Lightest primary */
var(--rinch-primary-color-9)  /* Darkest primary */

Available palettes: dark, gray, red, pink, grape, violet, indigo, blue, cyan, teal, green, lime, yellow, orange.

Semantic Colors

These flip automatically in dark mode:

var(--rinch-color-body)        /* Background (#f8f9fa light, dark gray dark) */
var(--rinch-color-text)        /* Primary text */
var(--rinch-color-dimmed)      /* Secondary text */
var(--rinch-color-border)      /* Borders */
var(--rinch-color-placeholder) /* Input placeholders */

Spacing

var(--rinch-spacing-xs)   /* 10px */
var(--rinch-spacing-sm)   /* 12px */
var(--rinch-spacing-md)   /* 16px */
var(--rinch-spacing-lg)   /* 20px */
var(--rinch-spacing-xl)   /* 32px */

Border Radius

var(--rinch-radius-xs)       /* 2px */
var(--rinch-radius-sm)       /* 4px */
var(--rinch-radius-md)       /* 8px */
var(--rinch-radius-lg)       /* 16px */
var(--rinch-radius-xl)       /* 32px */
var(--rinch-radius-default)  /* Whatever you set in ThemeProviderProps */

Typography

var(--rinch-font-size-xs)   /* 12px */
var(--rinch-font-size-sm)   /* 14px */
var(--rinch-font-size-md)   /* 16px */
var(--rinch-font-size-lg)   /* 18px */
var(--rinch-font-size-xl)   /* 20px */

var(--rinch-line-height-xs) /* 1.4 */
var(--rinch-line-height-md) /* 1.55 */

var(--rinch-font-family)
var(--rinch-font-family-monospace)

/* Heading sizes (h1-h6) */
var(--rinch-h1-font-size)
var(--rinch-h1-line-height)
var(--rinch-h1-font-weight)

These drive the Title component. A bare <h1>–<h6> does not read them: it gets the browser’s own user-agent typography instead — 2em down to 0.67em, font-weight: bold, and em block margins, exactly the values the HTML Standard’s rendering section gives (issue #627). <th> likewise gets font-weight: bold and a centring. Those are UA-origin cascade rules, so any declaration of your own beats them, and Title (which declares its own size, weight and margin: 0) is unaffected. Reach for Title when you want the theme’s scale and a plain heading tag when you want the browser’s.

The <th> centring is conditional, as it is in a browser: the spec’s rule matches only a th whose parent’s computed text-align is still the initial value, so a header cell inside a container you have aligned inherits that alignment rather than being re-centred. If you want a header cell centred regardless, say so on the cell — an author declaration always wins.

The rest of the browser’s defaults

The same audit (issue #674) finished the job for the elements around a heading, every value measured in Chrome 150 and every one a UA-origin cascade rule your own declaration beats:

elementwhat it now gets
p, blockquote, figure, ul, ol, menu, dir, premargin-block: 1em
blockquote, figuremargin-inline: 40px
ddmargin-inline-start: 40px
menu, dirthe whole ul treatment — display: block and padding-left: 40px as well
one of ul/ol/menu/dir inside anothermargin-block: 0 — no compounding gap per level
prewhite-space: pre, so it preserves runs of spaces and newlines
code, kbd, samp, prefont-family: monospace
small, sub, supfont-size: smaller
hrcolor: gray, a 1px inset border, margin-block: 0.5em, margin-inline: auto, height: 0, overflow: hidden

Before this an <hr> rendered nothing at all — the UA sheet’s own * { border-width: 0 } reset, which is there to undo Stylo’s medium initial, applied to <hr> too and nothing put the border back.

Four things about that set are worth knowing before they surprise you.

  • The <hr> rule is coloured through color, not border-color. The border is currentcolor, so <hr style="color: #c00"> gives you a red rule and there is no need to restate the border. border: none removes it outright, which is what the Divider component does.
  • A bare <hr> in a Stack collapses to a dot. margin-inline: auto are auto cross-axis margins on a flex item, and those suppress the stretch that would otherwise give it the container’s width — so it shrinks to its content width, which for an <hr> is nothing, and centres. A browser does exactly the same. Use Divider, or give the rule a width: 100%.
  • Whitespace in <pre> is preserved now, which means a <pre> whose source was indented in your rsx! will show that indentation. That is the element doing its job; white-space: normal on it opts out.
  • rinch does not copy Chrome’s monospace font-size quirk. Chrome renders a medium-sized monospace element at 13px rather than 16px; rinch has no per-family default size, so <code> keeps the size it inherits. Only the family comes from the UA sheet.

Text controls

A raw <input> or <textarea> carries the browser’s own box and font (issue #1194, every value measured in Chrome 153). The input components (TextInput, Textarea, NumberInput, PasswordInput, ColorInput) declare their own border, padding and font size, so this is about controls you write by hand:

controlboxfont and text
text-state <input> (no type, text, search, email, password, number, …)padding: 1px 2px, border: 2px inset grey13.3333px Arial, weight and style normal, line-height: normal
date/time <input>padding: 0 0 0 1px (inline start only), the same borderthe same, in monospace
checkbox, radio, range, file, image, hiddennonethe same 13.3333px font
the button types, colornone in rinch — Chrome gives submit/reset/button 1px 6px and a 2px outset border, color 1px 2px and a 1px solid one (not modelled yet)the same 13.3333px font
<textarea>padding: 2px, border: 1px solid grey13.3333px monospace, white-space: pre-wrap, overflow-wrap: break-word

Both controls also reset letter-spacing, word-spacing, text-transform, text-indent and text-align rather than inheriting them. The font is not cosmetic: a control is size (or cols) average characters of its own font wide.

Overflow follows Chrome too. An <input> other than a checkbox, radio or range is overflow: clip, and stays clip whatever you declare (the UA rule is !important). A <textarea>’s computed overflow is auto, and a visible you declare on either axis computes auto (a hidden or clip is kept), so a raw textarea shrinks as a flex item instead of holding its full width. rinch does not yet lay out or scroll a textarea’s text, so it shows no scrollbar and takes no wheel even when its value overflows.

Everything else is an ordinary UA rule your own declaration beats. The theme’s button, input, select, textarea { font-family: inherit } is one such declaration, so under the theme a raw control takes the page’s family and keeps the 13.3333px size — as it does in a browser under the same sheet. The colours (FieldText on Field) are not copied: the theme gives form controls color: inherit on purpose, so they stay readable in dark mode.

vertical-align: sub/super on <sub>/<sup> is now part of this (issue #724): ComputedStyle::vertical_align plus its ifc::vertical_align_shift_px consumer shift the glyph post-layout, calibrated against Chrome 153 and the bundled Inter at a 16px parent font-size (exact only there — see that doc for the numbers). top/text-top/middle/bottom/text-bottom parse but still lay out as baseline, and the shift does not grow the line box the way Chrome’s does, both filed as #1357.

List markers on <li> (issue #725) are partly part of this. li computes display: list-item, and the UA sheet gives lists Chrome’s own defaults: ul, menu and dir are disc, ol is decimal, a list inside one other list is circle and inside two is square — an ol counts as a level, so ol > li > ul is circle, measured in Chrome 153. A block-level list-item box lays out as a block one does (its children stack, its height is theirs); display: inline list-item stays inline.

What desktop draws is a text marker followed by an en space: •, ◦ or ▪ for disc, circle or square, N. for decimal (<ol start> and <li value> included), and the old bullet or number for any other keyword. It is generated as a span at the start of an <li> whose parent is a <ul> or <ol>, whose computed display is list-item, whose list-style-type is not none and which has no ::before or ::after of its own (Chrome draws the marker beside either; a menu/dir item gets none yet — #1370). So an item restyled to display: flex — a List item with an icon — draws no marker. The marker is inline content of the item, not an outside marker hanging in its padding, and list-style-position is not read (#1356). The rich-text editor’s stylesheet takes that span out of flow and hangs it against the item’s left edge, as a browser’s outside marker hangs, and draws its task-list checkbox itself as a ::before hung the same way (#1246).

Components are unaffected, and that is pinned rather than assumed — Divider, List, Breadcrumbs, Tree, Image, Blockquote and Code all declare margin: 0, so the author cascade beats every new rule and nothing you render through the component library moves.

The rich-text editor has one exception: its <sub> and <sup> shrink. Its stylesheet declares its own margin for every block tag above, its own font-family for code/pre and white-space: pre-wrap on pre, so all of that is unmoved — but it declares no font-size for sub/sup, which it renders as literal elements. So editor subscripts and superscripts now take the browser’s smaller, exactly as they always have in a browser and on rinch-web. Declare a font-size on them if you want the old desktop size back.

Letter and word spacing

letter-spacing and word-spacing apply to laid-out and painted text on desktop (issue #698). Both inherit, both take a length, and both follow css-text-3: the extra advance is added after every typographic character unit, the last one included, so letter-spacing: 4px over five characters widens the line by 20px rather than 16. word-spacing adds its length to every space and no-break space. Measured against Chrome 150, rinch and the browser agree on all three numbers.

A declaration on an inline element covers that element’s own characters only:

#![allow(unused)]
fn main() {
rsx! {
    p { style: "letter-spacing: 0.08em",
        "Spaced out, "
        span { style: "letter-spacing: normal", "but not here." }
    }
}
}

A percentage resolves against the element’s own font-size (issue #743), on both properties: letter-spacing: 50% / word-spacing: 50% add 10px per affected character at font-size: 20px, and calc(5px + 50%) adds 15px — measured in Chrome 153 and matched here, including word-spacing, whose percentage css-text-4 describes as relative to the space glyph’s own advance rather than the font-size; Chrome’s actual behaviour (and rinch’s, now) is the font-size basis, not that reading.

normal is zero, as in CSS, and it is a genuine reset: an inline element declaring it inside a spaced ancestor gets no spacing on its own characters, which is what the span above relies on.

Form controls are the exception. An <input>, <textarea> or <select> shapes its own value through a separate text path that does not read either property yet, so a spacing declared on one has no effect on the text inside it. Tracked as issue #320, which unifies that path with the rest.

Scaling the whole UI with rem

rem lengths resolve against the computed font-size of the root (<html>) element, exactly as in a browser. The root defaults to 16px, so 1rem is 16px until you say otherwise; html { font-size: 20px } (or a relative value like 125%, resolved against the 16px initial) rescales every rem length in the app — stylesheets and inline styles alike — which makes a root font-size rule the one-line way to scale a rem-based design up or down. Changing it at runtime recascades the document, and the basis survives window resizes. em is unaffected: it keeps resolving against the element’s own inherited font-size.

Resolution media queries follow the display

The shell feeds the window’s scale factor to Stylo as device_pixel_ratio, so @media (min-resolution: 2dppx) (and the -webkit-device-pixel-ratio forms), image-set() candidate selection, and border-width device-pixel snapping all reflect the display the window is actually on — from the first paint, and re-resolved when the window moves to a display with a different scale. Fractional scales (1.25, 1.5) match fractionally, as in a browser. This never changes layout geometry: 1 CSS px stays 1 layout unit, and paint applies the scale. One visible consequence: border: 0.5px on a 2x display renders as a real hairline instead of being snapped up to 1px.

Generic font families on Android

monospace, ui-monospace, ui-sans-serif and ui-serif resolve to a real device face on Android. They do not out of the box: the font backend looks the monospace slot up under the literal name "monospace", which /system/fonts is not indexed under, so the slot ends up empty and every <code>/<pre> — and the whole default font_family_monospace stack, whose every name is absent on Android — renders proportional. rinch repairs the map when it builds a font context (rinch_dom::fonts::new_font_context), appending the first candidate the device actually has: Roboto Mono, Droid Sans Mono, Noto Sans Mono, Cutive Mono for the monospace slots. Only slots the platform left empty are touched, and no other platform is affected. ui-rounded and fangsong are left empty — Android ships no face for either, on any platform they fall through to the next entry in the stack.

Shadows

var(--rinch-shadow-xs)
var(--rinch-shadow-sm)
var(--rinch-shadow-md)
var(--rinch-shadow-lg)
var(--rinch-shadow-xl)

Window Chrome Inset

var(--rinch-window-top-inset)  /* Height of the chrome rinch draws above your content */

Rinch sometimes draws window chrome itself: the DOM menu bar (28px — on Linux, and on the web under rinch_web::mount_with_menu_bar) and the BorderlessWindow titlebar (36px). That chrome reserves space with in-document padding, so normal flow content clears it automatically.

A position: fixed element does not. Fixed elements resolve against the real viewport — that is what browsers do, and what rinch-web does on the real DOM — so a top: 0 overlay slides underneath the menu bar or titlebar.

The chrome therefore publishes its own height as --rinch-window-top-inset. It’s an ordinary inherited custom property, so any fixed descendant can opt in:

#![allow(unused)]
fn main() {
rsx! {
    div { style: "
        position: fixed;
        top: var(--rinch-window-top-inset, 0px);
        left: 0; right: 0; bottom: 0;
    ",
        "Full-height overlay that clears the menu bar"
    }
}
}

The 0px fallback matters: on a plain window there is no chrome and the variable is never set, so the overlay stays flush with the top.

Drawer, Modal, and the top-anchored Notification positions already do this. DropdownMenu’s and Select’s click-catching backdrops are fixed and deliberately do not — a dismiss region has to cover the chrome, or clicking the title bar would leave the menu open.

Using Theme Variables in Your Styles

#![allow(unused)]
fn main() {
rsx! {
    div { style: "
        background: var(--rinch-color-body);
        padding: var(--rinch-spacing-md);
        border-radius: var(--rinch-radius-default);
        box-shadow: var(--rinch-shadow-sm);
    ",
        h1 { style: "color: var(--rinch-primary-color);", "Themed!" }
        p { style: "color: var(--rinch-color-dimmed);", "Using theme variables." }
    }
}
}

Or use CSS shorthand props, which resolve scale values automatically:

#![allow(unused)]
fn main() {
rsx! {
    div { p: "md", m: "sm",  // -> var(--rinch-spacing-md), var(--rinch-spacing-sm)
        "Shorthand is nicer"
    }
}
}

CSS Selector Support (desktop)

Desktop styling runs through Stylo, the same cascade engine Servo uses, so specificity, inheritance, custom properties and @media behave as you expect. The selector surface is narrower than a browser’s, and the gaps are silent: an unsupported selector parses fine and then matches nothing, with no warning. On rinch-web the browser does its own matching, so everything below works there — which is exactly what makes a gap easy to miss.

Measured on the desktop engine:

SelectorDesktopNote
tag, .class, #id, *works#id was silently dropped before #675
an UPPERCASE HTML tag name, in the markup (<DIV>, <Button>)worksas of #739 — a tag is stored ASCII-lowercased in HTML content, matching Stylo’s own lowercased type-selector comparison for an HTML element. A tag that is one of SVG’s own camelCase spellings, however it was cased, is restored to that spelling rather than lowercased (attr_name::fold_tag_name), mirroring #688’s attribute-name fold one row down
descendant / > / + / ~works
[attr], [attr=v], ~=, |=, ^=, $=, *=works
an UPPERCASE attribute name, in the selector or the markup ([DATA-X], <div ID="up">)worksas of #688 — attribute names are stored ASCII-lowercased in HTML content, and Stylo lowercases the selector’s name itself. viewBox and the other camelCase SVG attributes are kept verbatim
[attr=v i] / [attr=v s] (case flags)workssince the style-invalidation work: attribute matching goes through the selectors crate’s own evaluator, the same one Stylo’s invalidation snapshots use. This is the value; the name above is a different question
:hover, :active, :focus, :focus-visibleworks
:enabled, :disabled, :checkedworksform controls only, by design
:link, :any-linkworks<a>/<area> with an href
:visitednever matchesrinch tracks no history
:first-child, :nth-child(), :not(), :is(), :where(), :root, :emptyworkshandled by the selector engine itself
:has()never matchesstylo’s servo-feature SelectorParser answers parse_has() -> false (#680), so div:has(em) { … } never parses into a relative selector in the first place — an upstream-dependency limitation, not a rinch-dom matching bug, and fixing it needs a patched/forked stylo (the same shape as the vendored wgpu fork)
:required, :optional, :read-only, :read-write, :placeholder-shown, :defined, :lang()works (#681)narrower than the full CSS definition — see Node::tag_supports_required / Node::tag_is_readonly_capable’s docs for the exact scope each leaves out (no <fieldset disabled> inheritance, no non-form-control :read-only; :lang()’s invalidation is a forced self+subtree+later-siblings restyle on a lang write, not Stylo’s state maps)
:focus-withinnever matchesattempted for #681 and reverted: an eager ancestor-propagated bit went stale under style_invalidation_twin_tests.rs’s random-mutation oracle (.a:focus-within) whenever a subtree was Moved while focus lived elsewhere — see stylo_impl.rs’s comment at the pseudo-class match’s catch-all
:indeterminate, :valid, :invalid, :in-range, :out-of-range, :default, :target, :fullscreen, :modal, :popover-open, :autofill, :user-valid, :user-invalidnever matchthey parse, then fall through to a catch-all false — rinch has no constraint validation, no document/fragment concept, and no “default” tracking distinct from current state
::before, ::afterworks
camelCase SVG type selectors (linearGradient, clipPath)works (#683)is_html_element_in_html_document answers false for SVG content tags (attr_name::is_svg_content_tag), so the selector keeps the author’s exact spelling instead of being lowercased as if every element were HTML
width / height attributes on <img>, <video>, <iframe>worksmapped to width / height below every author rule and above the UA sheet’s normal rules, plus the pair’s aspect ratio on an image or video (#684)
Other presentational attributes (<td bgcolor>, <img hspace>, <hr width>, <table width>)no effectno hint is synthesized for them (#1419)

Reach for a class where the table says a selector does not match — that is the one spelling with no gap on either backend.

Text decoration (desktop)

text-decoration-line, -color and -style all reach the desktop painter. text-decoration-style: wavy draws a zigzag under the text — the spellcheck squiggle — in the decoration colour, or the text colour when none is given. The other styles (double, dotted, dashed) parse and paint as a solid line. A decoration propagates to descendants as in CSS: a wavy word inside an underlined link keeps the link’s line and gains the wave. The wave follows the text on screen, right-to-left and mixed-direction runs included.

Extending the Theme

Scoped Overrides

CSS variables cascade. Override them on a container div and everything inside picks up the change:

#![allow(unused)]
fn main() {
rsx! {
    div { style: "
        --rinch-primary-color: #ff6b6b;
        --rinch-radius-default: 0px;
    ",
        // Red primary, sharp corners — only inside this div
        Button { "I'm red and sharp" }
        TextInput { placeholder: "Me too" }
    }
}
}

This is how you create themed sections, cards with different accent colors, or “danger zone” areas with red buttons — without changing the global theme.

Replacing the Theme Entirely

The theme is just CSS. If you hate it, replace it:

#![allow(unused)]
fn main() {
use rinch::theme::{Theme, ColorName};

let theme = Theme::builder()
    .primary_color(ColorName::Cyan)
    .dark_mode(true)
    .build();

// Generate the CSS string and do whatever you want with it
let css = rinch_theme::generate_theme_css(&theme);
}

Or ignore the theme system entirely and write your own CSS variables, your own classes, your own inline styles. Rinch components read from --rinch-* variables. If those variables exist, components use them. If they don’t, components fall back to hardcoded defaults. You’re never locked in.

Dark Mode

#![allow(unused)]
fn main() {
let theme = ThemeProviderProps {
    dark_mode: true,
    ..Default::default()
};
}

In dark mode, semantic colors flip:

  • --rinch-color-body becomes dark gray
  • --rinch-color-text becomes light
  • Component backgrounds, borders, and hover states all adjust

For dynamic dark mode toggling (e.g., a switch in your app), use dark_mode_fn on WASM or re-create the theme and call update_theme() on desktop.

Scrollbars

A desktop scroll container paints an overlay thumb on each axis that is scrollable and overflowing. Pressing the thumb grabs it where it is, and dragging moves it with the pointer; pressing the empty track beside it jumps there, a position along the track mapping linearly onto the scroll range. Two custom properties style it, and both inherit — one declaration on a root node restyles every scroll region in the app.

:root {
  --rinch-scrollbar-color: rgba(255, 255, 255, 0.35);              /* thumb */
  --rinch-scrollbar-color: rgb(120 120 130) rgba(0, 0, 0, 0.15);   /* thumb, track */
  --rinch-scrollbar-width: auto;                                   /* auto | thin | none */
}
  • --rinch-scrollbar-color: <thumb> [<track>] — any CSS colour. With one colour only the thumb is painted, which is rinch’s default overlay look; with two, a track is painted behind it for the full length of the bar. auto (the initial value) keeps the built-in default described below.
  • --rinch-scrollbar-width: auto | thin | none — auto is a 6px thumb, thin a 4px one. none removes the bar entirely: nothing is painted and nothing is hit-tested either, so an app drawing its own scrollbar can switch rinch’s off rather than covering it up.

What counts as overflowing

For the cases in this paragraph, rinch counts the same content a browser does. A position: fixed child is anchored to the viewport, so it never makes an ancestor scroll — a Modal, Drawer or Notification inside an overflow: auto region adds nothing to that region’s scroll range, open or closed. A position: absolute child counts only where its containing block is the container or something inside it — the container is itself positioned (or transformed), or an inline element between them is, such as a position: relative <span> holding the absolute; an absolute that resolves against something further up escapes that container, exactly as it does on the web. visibility: hidden content still counts, because it still occupies its box; display: none content does not, because it has none. The children of a display: contents element count as the container’s own, since the wrapper has no box — so a region whose rows come from a for, an if branch or an embedded Vec<NodeHandle>, each of which rsx! may put in such a wrapper, scrolls as it would on the web, and so does one holding text — a reactive {|| text} included — whether it is the container’s only content or sits beside block children, as a log panel’s footer line sits after its rows.

Where desktop still differs from a browser:

  • An absolute child of a padded scroll container can add up to the container’s right and bottom padding to its scroll range, which a browser does not. A position: absolute; inset: 0 overlay — a LoadingOverlay, say — inside a padding: 20px; overflow: auto; position: relative panel gives the panel 20px of scroll travel on each axis and two scrollbars, where a browser shows none.
  • A position: relative child with an offset counts only where the offset puts it, while a browser also counts where it would have been. Shifted toward the end of a padded container, it can add up to the container’s right or bottom padding; shifted toward the start, it can take away up to the offset, padded container or not — a 240px-wide child at left: -40px in a 200px-wide overflow: auto box gets no scrollbar, where a browser gives 40px of travel. A relative child with no offset is measured like any other.
  • An absolute whose containing block is further up than its parent is laid out in that containing block (#386) but counted by no scroll container at all; a browser counts it in that ancestor (#770). The exception is a parent that is a non-positioned inline element such as a plain <span> (at any depth of them) sitting directly in the containing block: the box then counts there, as a browser counts it. Under a position: relative span — which is then the box’s containing block — it counts when the scroll container is the block holding the span’s line, placed against the span’s text (#1049, #631); with another block between them it is counted nowhere (#770).
  • A child’s transform and its end margins do not extend the scroll range; a browser’s scroll range includes both.

Why not scrollbar-color / scrollbar-width?

Those are the real CSS properties, and they are what these mirror — but they are gecko-only in Stylo. That is a codegen-time filter rather than a #[cfg], so the servo build rinch uses emits no parser entry for either and drops the declaration. The --rinch- custom properties go through Stylo’s custom-property cascade instead, which the servo build supports fully. If Stylo ever ships the real properties for servo, they will be honoured alongside these.

Neither affects layout: rinch’s bar is an overlay drawn on top of content and takes no space, so thin and none change what you see, not where anything sits.

The default follows your palette

auto is not a fixed colour. The thumb is 40% black or 40% white, chosen by the luminance of the container’s own computed color. A light theme’s text is dark, so the thumb is the 40% black it has always been and nothing changes; a dark theme’s text is light, so the thumb flips to white and is visible.

Only the polarity follows the palette — the thumb stays neutral grey — so a container that happens to set color: red does not get a red thumb. color is read rather than background-color because it is always resolved: backgrounds are transparent by default, so a scroll region inheriting the page’s dark background would have nothing to read.

Desktop only. On the web the browser draws its own scrollbars, and the real CSS properties work there.

Color Palette Reference

ColorShade 0 (lightest)Shade 6 (primary)Notes
dark#C1C2C5#1A1B1EDark grays
gray#f8f9fa#868e96gray-0 = default body background
red#fff5f5#fa5252
pink#fff0f6#e64980
grape#f8f0fc#be4bdb
violet#f3f0ff#7950f2
indigo#edf2ff#4c6ef5
blue#e7f5ff#228be6Default primary
cyan#e3fafc#15aabf
teal#e6fcf5#12b886
green#ebfbee#40c057
lime#f4fce3#82c91e
yellow#fff9db#fab005
orange#fff4e6#fd7e14

Gotcha: --rinch-color-gray-0 is #f8f9fa, which matches the default body background. If you use it as a card background, it’ll be invisible. Use gray-1 or higher for visible backgrounds.

CSS sizing keywords

For width, height, min-width/min-height, max-width/max-height and flex-basis, rinch supports lengths, percentages, calc() mixing the two, auto and none. The intrinsic sizing keywords are supported on width, height and flex-basis (#691), and parse but lay out as auto on min-*/max-* (#1275).

Valuewidth / height / flex-basismin-* / max-*
<length>, <percentage>, calc() of the twoSupportedSupported
auto, noneSupportedSupported
max-contentSupportedLaid out as auto
min-contentSupportedLaid out as auto
fit-contentSupportedLaid out as auto
stretch, -webkit-fill-availableSupportedLaid out as auto
fit-content(<length-percentage>)auto — Chrome 153 does not accept it on these properties eitherLaid out as auto
anchor-size(…)Laid out as autoLaid out as auto

They are checked against Chrome 153 in block, flex (both axes, and flex-basis), grid, inline-block/inline-flex, absolute and fixed contexts (crates/rinch-dom/tests/intrinsic_sizing_twin_tests.rs). What still differs, each with its issue:

  • fit-content around content that wraps comes out as wide as its widest wrapped line rather than the available width (#1276) — the same measurement that makes an auto-width absolute too narrow. It is right whenever the content fits on one line, or cannot wrap at all.
  • height: stretch on an inline-block lays out as auto, and width: stretch on a flex item whose content is wider than the container is not capped by it (#1277).
  • An inline-block (or inline-flex, inline-grid) whose width is auto is shrink-to-fit since #658, as in a browser: capped at its containing block, so wider content wraps there. That holds where the block’s own width comes from the box too (#1476): inside a flex item, a grid item in an auto track, a fit-content or min-content block, a shrink-to-fit absolute box or another auto atomic inline, the container is sized from the box’s min-content and max-content widths (its longest word, its one-line width), and the box wraps in what that gives. Two things in this corner are not a browser’s yet. A box whose own min-width or max-width is a percentage, or whose width is fit-content or a percentage, still contributes the size it currently has to such a container, as before, so it is not a browser’s there: a width: fit-content box keeps its full width, and a max-width: 80% box comes out narrower than a browser’s and narrower again on each relayout (older than #1476). A box whose width is a percentage, in a flex item or an auto grid track, is the same cycle: its container is sized from the box and the box from its container, so the first layout is not where it settles — it drifts over the next few layouts, and since #1476 an auto box in a neighbouring item or track moves with it (up to seven layouts, measured). A box that fits within a pixel of its containing block is left unwrapped (a 380.9px line in a 380px block overflows by that pixel).

For min-*/max-* rinch prints one line on stderr per property and keyword per process, so the substitution is visible rather than silent:

[rinch] `min-width: max-content` is not implemented; it lays out as `auto`,
which matches a browser only where `auto` already gives the same used size
(issues #626, #1275). Reported once per property and value per process.

Grid tracks take the keywords too: grid-template-columns: max-content and min-content size a column the way a browser does, and fit-content(<length-percentage>) is converted for tracks.

When auto is the right answer for min-*/max-*

On min-*/max-*, where every keyword is still laid out as auto, whether that changes anything depends on the box. Measured against Chrome 150, for a box whose content is 300px wide inside an 800px containing block:

The boxauto givesmax-content/min-content/fit-contentstretch
min-width / max-widthno constraintwrong — should constrain to 300correct
min-heightnonecorrectwrong — should fill the containing block
max-heightnonecorrectcorrect

A browser shrink-wraps a float too. rinch does not, because rinch implements no CSS float at all — measured, float: left on the same box gives 800, the full containing block, where a browser gives 300. So a float is not a way to shrink-wrap a box here; width: fit-content (or display: inline-block) is.

Where a min-*/max-* keyword is wrong, a declared length or percentage is the workaround: min-height: 100% gives min-height: stretch’s used size as long as the box has no margin, border or padding on that axis.