Material 3 tokens for QML — colour roles, the type scale, shape, spacing, density and state layers, as a drop-in module.
import QuickMaterial
Rectangle {
color: Colours.surfaceContainer
radius: Corner.large
Text {
text: "Sign in"
font: Type.titleMedium
color: Colours.on.surface
}
}Roles, not colours. Nothing here is named for what it looks like, so a scheme can be swapped underneath and every call site stays correct. That is the whole point of a design system, and it stops working the moment anything reaches for a literal.
- Qt 6.6 or newer,
qt6-declarative - quickmotion 0.3.0 or newer —
StateLayerandLoadingIndicatorare built on it. The six token singletons work without it; those two components will not load.
git clone https://github.com/Neftedollar/quickmaterial
cd quickmaterial
sudo ./install.sh # or ./install.sh --user, no rootGoes into Qt's QML import path — asked from Qt rather than assumed, so it
lands correctly on distributions that do not use Arch's layout. DESTDIR,
PREFIX and QMLDIR are honoured for packaging, and packaging/PKGBUILD
builds an Arch package.
Twenty-odd roles, in five groups: primary, secondary and tertiary for accents; error; and the surface ladder that carries elevation.
Elevation in Material 3 is tonal rather than a shadow — a raised surface is
a different container role, not the same colour with a shadow under it.
surfaceContainerLowest through surfaceContainerHighest are that scale.
Colours.primary
Colours.surfaceContainerHigh
Colours.on.surface
Colours.on.primaryContainerForeground roles live under on rather than being spelled onSurface,
because QML parses an identifier of the form on + Uppercase as a signal
handler and a property called onSurface will not compile at all.
Themes.use("baseline") // or "slate"
Colours.mode = "light" // switches instantly, no reloadTwo schemes ship: baseline is the Material 3 baseline in both modes,
slate is a cool dark one. A scheme carrying both modes is what lets
mode switch without re-reading anything.
External schemes are somebody else's job to read — hand the parsed object
to applyScheme:
Colours.applyScheme(JSON.parse(text))Not an oversight. QML has no filesystem watcher, and Qt refuses to read
local files over XMLHttpRequest unless QML_XHR_ALLOW_FILE_READ is set in
the environment — which a library is in no position to demand of everything
that uses it. An application with a file watcher gets live reloading for
free this way; Quickshell's FileView does both in one property.
Two shapes are accepted: { light: {…}, dark: {…} }, and the flat
{ mode: "dark", colours: {…} } that generators and caelestia write.
Foreground roles keep their flat onSurface spelling in JSON — the nesting
is this library's concern, not the file format's. inverseOnSurface is
accepted alongside onInverseSurface, since that is what generators
actually emit.
Values must be #RRGGBB or #AARRGGBB. Anything else is rejected with a
warning naming the role, and the role keeps its previous value. Qt does not
throw on an unparseable colour — it silently yields black — so a scheme in
the wrong dialect would otherwise produce a black interface reported as a
success.
Fifteen styles in five roles, assigned as a whole font rather than a size:
Text {
font: Type.bodyLarge
lineHeightMode: Text.FixedHeight
lineHeight: Type.line.bodyLarge
}A size on its own is not a type style. Weight, tracking and line height travel with it, and picking a size while leaving the other three at their defaults is how text ends up almost right in a way nobody can name.
Line height is separate because a QML font cannot carry one. Set it only where text actually wraps.
Type.family and Type.iconFamily are settable — neither Roboto nor
Material Symbols is packaged everywhere, and a hard requirement on a
missing font renders an interface in whatever the fallback is, which for an
icon font is empty boxes.
Rectangle { radius: Corner.large } // 16
Rectangle { radius: Corner.full(height) } // a pillSeven steps and nothing in between: 0, 4, 8, 12, 16, 28, and full. The
value of a scale is that it is short — radii picked individually drift into
9, 10, 11, 13, 14, numbers nobody chose and no two components share.
Corner.snap(14) gives the nearest step, for migrating a codebase that
grew its own.
Named Corner rather than Shape, because Shape is the type
QtQuick.Shapes exports and a singleton of that name collides with it in
both import orders — one of them silently, leaving the radius 0.
Metrics.field // 56
Metrics.density = -2
Metrics.field // 48Material's default sizes assume a finger: a text field is 56 tall because that is comfortable to hit without looking. On a desktop driven by a keyboard and a precise pointer that is generous, and there is a documented answer — a density scale from 0 down to −3, each step subtracting 4.
So a compact desktop interface is not a departure from the specification but a stated point on it, and saying which point is the difference between a decision and numbers that drifted. Reduce density for dense, information-rich, expert interfaces; leave it at 0 for anything glanced at, used rarely, or operated at arm's length.
Metrics.minimumTarget is 48 and does not scale with density. It is an
accessibility floor, not a style: a control drawn smaller should grow its
hit area rather than shrink this.
Rectangle {
color: Colours.surfaceContainer
radius: Corner.large
StateLayer {
anchors.fill: parent
rounding: parent.radius
colour: Colours.on.surface
hovered: area.containsMouse
focused: control.activeFocus
}
}A translucent film of the content colour laid over the component, rather than the component changing to a different colour.
The difference is not academic. Swapping surfaceContainer for
surfaceContainerHigh works only where a container role is already
painted; on a transparent control — a bare icon button, a list row with no
background — the same swap jumps from nothing to a filled box, which is why
those flash a rectangle on hover. A layer over transparency is simply a
faint tint, which is what was wanted.
Only one layer shows at a time, chosen by priority: pressed, then focused, then hovered. They do not add.
Set ripple: true for a press wave from the point of contact — and pass
the control's area with it, or Ripple creates its own MouseArea over
the control and swallows its clicks.
Colours |
the colour roles, and applying a scheme |
Type |
the fifteen-style type scale |
Corner |
the shape scale |
Metrics |
component sizes, spacing, density |
States |
state layer opacities |
Themes |
the schemes that ship with the library |
StateLayer |
hover, focus and press, the Material way |
LoadingIndicator |
a shape that turns and changes while work runs |
quickgreet, a greetd greeter, and quickask, a polkit agent.
This is a 0.1. Names may still move before 1.0. Bugs and requests welcome.
MIT. See LICENSE.