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
| Prop | Type | Default | Description |
|---|---|---|---|
primary_color | Option<String> | "blue" | Primary color name |
primary_shade | Option<u8> | 6 | Shade index (0-9) |
default_radius | Option<String> | "sm" | Border radius (xs, sm, md, lg, xl) |
font_family | Option<String> | System fonts | Primary font stack |
font_family_monospace | Option<String> | System mono | Monospace font stack |
dark_mode | bool | false | Dark 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:
| element | what it now gets |
|---|---|
p, blockquote, figure, ul, ol, menu, dir, pre | margin-block: 1em |
blockquote, figure | margin-inline: 40px |
dd | margin-inline-start: 40px |
menu, dir | the whole ul treatment — display: block and padding-left: 40px as well |
one of ul/ol/menu/dir inside another | margin-block: 0 — no compounding gap per level |
pre | white-space: pre, so it preserves runs of spaces and newlines |
code, kbd, samp, pre | font-family: monospace |
small, sub, sup | font-size: smaller |
hr | color: 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 throughcolor, notborder-color. The border iscurrentcolor, so<hr style="color: #c00">gives you a red rule and there is no need to restate the border.border: noneremoves it outright, which is what theDividercomponent does. - A bare
<hr>in aStackcollapses to a dot.margin-inline: autoare 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. UseDivider, or give the rule awidth: 100%. - Whitespace in
<pre>is preserved now, which means a<pre>whose source was indented in yourrsx!will show that indentation. That is the element doing its job;white-space: normalon 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:
| control | box | font and text |
|---|---|---|
text-state <input> (no type, text, search, email, password, number, …) | padding: 1px 2px, border: 2px inset grey | 13.3333px Arial, weight and style normal, line-height: normal |
date/time <input> | padding: 0 0 0 1px (inline start only), the same border | the same, in monospace |
checkbox, radio, range, file, image, hidden | none | the same 13.3333px font |
the button types, color | none 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 grey | 13.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:
| Selector | Desktop | Note |
|---|---|---|
tag, .class, #id, * | works | #id was silently dropped before #675 |
an UPPERCASE HTML tag name, in the markup (<DIV>, <Button>) | works | as 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">) | works | as 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) | works | since 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-visible | works | |
:enabled, :disabled, :checked | works | form controls only, by design |
:link, :any-link | works | <a>/<area> with an href |
:visited | never matches | rinch tracks no history |
:first-child, :nth-child(), :not(), :is(), :where(), :root, :empty | works | handled by the selector engine itself |
:has() | never matches | stylo’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-within | never matches | attempted 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-invalid | never match | they 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, ::after | works | |
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> | works | mapped 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 effect | no 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-bodybecomes dark gray--rinch-color-textbecomes 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—autois a 6px thumb,thina 4px one.noneremoves 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
absolutechild 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. Aposition: absolute; inset: 0overlay — aLoadingOverlay, say — inside apadding: 20px; overflow: auto; position: relativepanel gives the panel 20px of scroll travel on each axis and two scrollbars, where a browser shows none. - A
position: relativechild 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 atleft: -40pxin a 200px-wideoverflow: autobox gets no scrollbar, where a browser gives 40px of travel. Arelativechild 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 aposition: relativespan — 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
transformand 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
| Color | Shade 0 (lightest) | Shade 6 (primary) | Notes |
|---|---|---|---|
dark | #C1C2C5 | #1A1B1E | Dark grays |
gray | #f8f9fa | #868e96 | gray-0 = default body background |
red | #fff5f5 | #fa5252 | |
pink | #fff0f6 | #e64980 | |
grape | #f8f0fc | #be4bdb | |
violet | #f3f0ff | #7950f2 | |
indigo | #edf2ff | #4c6ef5 | |
blue | #e7f5ff | #228be6 | Default primary |
cyan | #e3fafc | #15aabf | |
teal | #e6fcf5 | #12b886 | |
green | #ebfbee | #40c057 | |
lime | #f4fce3 | #82c91e | |
yellow | #fff9db | #fab005 | |
orange | #fff4e6 | #fd7e14 |
Gotcha:
--rinch-color-gray-0is#f8f9fa, which matches the default body background. If you use it as a card background, it’ll be invisible. Usegray-1or 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).
| Value | width / height / flex-basis | min-* / max-* |
|---|---|---|
<length>, <percentage>, calc() of the two | Supported | Supported |
auto, none | Supported | Supported |
max-content | Supported | Laid out as auto |
min-content | Supported | Laid out as auto |
fit-content | Supported | Laid out as auto |
stretch, -webkit-fill-available | Supported | Laid out as auto |
fit-content(<length-percentage>) | auto — Chrome 153 does not accept it on these properties either | Laid out as auto |
anchor-size(…) | Laid out as auto | Laid 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-contentaround content that wraps comes out as wide as its widest wrapped line rather than the available width (#1276) — the same measurement that makes anauto-width absolute too narrow. It is right whenever the content fits on one line, or cannot wrap at all.height: stretchon aninline-blocklays out asauto, andwidth: stretchon a flex item whose content is wider than the container is not capped by it (#1277).- An
inline-block(orinline-flex,inline-grid) whose width isautois 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 anautotrack, afit-contentormin-contentblock, a shrink-to-fit absolute box or anotherautoatomic 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 ownmin-widthormax-widthis a percentage, or whosewidthisfit-contentor a percentage, still contributes the size it currently has to such a container, as before, so it is not a browser’s there: awidth: fit-contentbox keeps its full width, and amax-width: 80%box comes out narrower than a browser’s and narrower again on each relayout (older than #1476). A box whosewidthis a percentage, in a flex item or anautogrid 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 anautobox 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 box | auto gives | max-content/min-content/fit-content | stretch |
|---|---|---|---|
min-width / max-width | no constraint | wrong — should constrain to 300 | correct |
min-height | none | correct | wrong — should fill the containing block |
max-height | none | correct | correct |
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.