Skip to content

API reference

Conditions and selectors

Every key a stylesheet accepts besides part names and tokens: states, breakpoints, containers, platforms, other parts, variant selectors and the $ fields. Each entry gives the forms a key can be written in and which form to prefer.

How to read a key

The first character says what kind of key it is:

PrefixKindExample
:A state of the part':hover'
@A condition: viewport, container or platform'@media md'
Part:A state of another part'Root:hover'
[…]A variant selector, written with $$.size('s')
$A field that is not a token$kind

A condition has up to three spellings, and all three declare the same rule. The builder is a call on q, which the stylesheet callback receives: [q.media('md')]. Its arguments are checked against the system and completed by the editor. The alias is the same condition as a string: '@media md'. The short key, '@md', is the string the builder returns and the alias is rewritten to before the sheet is compiled.

Use the alias or the builder. The short keys are the earlier spelling; they remain valid and existing sheets need no change.

Example

One sheet, written with aliases:

styles.ts
import { stylesheet } from './system.ts'

export const cardStyles = stylesheet({
  Root: {
    $kind: 'pressable',
    fill: 'surface',
    padding: 2,
    ':hover': { fill: 'accent' },
    ':focus-visible': { fill: 'accent' },
    ':open': { fill: 'accent-strong' },
    '@media md': { padding: 4 },
    '@container card wide': { padding: 6 },
    // Escape hatch: a web-only property that no token covers.
    '@platform web': { $style: { cursor: 'pointer' } },
  },
  Hint: { $kind: 'text', opacity: 0 },

  // At the sheet root, one condition holds rules for several parts.
  'Root:hover': { Hint: { opacity: 1 } },
  '@media lg': { Root: { padding: 6 }, Hint: { opacity: 0.6 } },
})

The same conditions from the builder. Pass a function to stylesheet to receive q. The builder is also the only way to combine conditions or to use a width the system does not name:

builder.ts
import { dp } from '@toned/core'
import { stylesheet } from './system.ts'

export const cardStyles = stylesheet(q => ({
  Root: {
    $kind: 'pressable',
    fill: 'surface',
    padding: 2,
    [q.state('hover')]: { fill: 'accent' },
    [q.state('open')]: { fill: 'accent-strong' },
    [q.media('md')]: { padding: 4 },
    [q.container('card', 'wide')]: { padding: 6 },
    // A width with no declared name.
    [q.media(dp(600))]: { padding: 4 },
    // Wide viewport, and not hovered.
    [q.all(q.media('lg'), q.not(q.state('hover')))]: { fill: 'surface' },
    [q.platform('web')]: { $style: { cursor: 'pointer' } },
  },
  Hint: { $kind: 'text', opacity: 0 },

  [q.part('Root').state('hover')]: { Hint: { opacity: 1 } },
  [q.media('lg')]: { Root: { padding: 6 }, Hint: { opacity: 0.6 } },
}))

Both sheets use this system. Breakpoints, containers and extra states are declared under conditions; a key that names anything else is a type error.

system.ts
import { defineSystem, defineToken } from '@toned/core'

const fills = {
  surface: '#ffffff',
  accent: '#315bd6',
  'accent-strong': '#2447b3',
} as const

export const ui = defineSystem({
  id: 'shop',
  tokens: {
    fill: defineToken({
      values: ['surface', 'accent', 'accent-strong'] as const,
      resolve: (value: keyof typeof fills) => ({ backgroundColor: fills[value] }),
    }),
    padding: defineToken({
      values: [2, 4, 6] as const,
      resolve: step => ({ padding: step * 4 }),
    }),
    opacity: defineToken({
      values: [0, 0.6, 1] as const,
      resolve: opacity => ({ opacity }),
    }),
  },
  conditions: {
    // Viewport widths, in logical pixels.
    media: { md: 768, lg: 1024 },
    // Named containers and their width steps.
    containers: { card: { wide: 448 } },
    // Extra states: a name and the selector that marks it.
    states: { open: '[data-state="open"]' },
  },
})

export const { stylesheet } = ui

States

A state key goes inside a part and applies while that part is in the state.

KeyBuilderApplies while
':hover'q.state('hover')the pointer is over the part
':active'q.state('active')the part is being pressed
':focus'q.state('focus')the part has focus
':focus-visible'q.state('focus-visible')the part has focus and the browser shows a focus indicator
':focus-within'q.state('focus-within')the part or something inside it has focus
':open'q.state('open')the part matches a state declared in conditions.states
':open:hover'q.all(q.state('open'), q.state('hover'))both states hold

On the web the states are CSS: no event listeners are attached. ':focus-visible' and ':focus-within' have no event to pair with on React Native, so a native host must supply them. See Interactive styles.

Viewport and containers

BuilderAliasShort keyApplies when
q.media('md')'@media md''@md'the viewport is at least as wide as the md breakpoint
q.media(dp(600))—'@>=600px'the viewport is at least 600 logical pixels wide
q.container('card', 'wide')'@container card wide''@card/wide'the nearest card container is at least as wide as its wide step
q.container('card', dp(300))—'@card/>=300px'the nearest card container is at least 300 logical pixels wide

Every condition is a minimum width, so declarations read from narrow to wide: the base value first, then the breakpoints that replace it. A part becomes a container by setting container: 'card' on it. The fixed-width forms take dp() from @toned/core; write them through the builder so the number is checked. Media queries covers breakpoints in more detail.

Platforms

BuilderAliasShort keyApplies on
q.platform('web')'@platform web''@platform.web'the web
q.platform('native')'@platform native''@platform.native'React Native

Outside a platform block, $style accepts only fields that mean the same on both platforms. Inside one it accepts that platform's own properties, which is why a web-only property such as cursor is written there.

Other parts

These keys go at the sheet root, or inside a variant rule, and hold rules for any of the sheet's parts.

KeyBuilderApplies while
'Root:hover'q.part('Root').state('hover')Root is hovered; styles Root and the parts inside it
'Root~:hover'—Root is hovered; styles the parts that follow it as siblings
—q.part('Root').has('Item', 'open')an Item part inside Root is in the state

The key names one part and one state. To depend on several, combine builder calls with q.all or q.any. Parts that depend on one another must be rendered inside the same element family or useStyles call.

Combining conditions

BuilderApplies when
q.all(a, b)every condition holds
q.any(a, b)at least one condition holds
q.not(a)the condition does not hold

They take any builder result, including a variant selector such as $.size('s'), and nest. The keys they return are generated strings: always use them as computed keys, never copy them out.

Variant selectors

Inside .variants(), the first callback argument builds the keys that select on the component's variant values.

button.styles.ts
import type { Variants } from '@toned/core'
import { stylesheet } from './system.ts'

type ButtonVariants = { size: 's' | 'm'; tone: 'plain' | 'accent'; busy?: boolean }

export const buttonStyles = stylesheet({
  Root: { $kind: 'pressable', fill: 'surface', padding: 4 },
}).variants(($: Variants<ButtonVariants>, q) => ({
  // A fragment: no selector of its own, reused through $compose.
  [$('dimmed')]: { Root: { opacity: 0.6 } },

  [$.size('s')]: { Root: { padding: 2 } },
  [$.tone('accent')]: { Root: { fill: 'accent' } },
  // Both values must be selected.
  [$.size('s').tone('accent')]: { Root: { fill: 'accent-strong' } },
  [$.busy(true)]: { $compose: 'dimmed' },
  // A variant and a condition together.
  [q.all($.tone('accent'), q.media('md'))]: { Root: { padding: 6 } },
}), { defaults: { size: 'm', tone: 'plain' } })
BuilderKey it returnsSelects
$.size('s')'[size=s]'one value of one axis
$.size('s').tone('accent')'[size=s][tone=accent]'both values at once; the order of the calls does not matter
$.busy(true)'[busy=true]'a boolean axis
$('dimmed')—nothing: it names a fragment for $compose

Write selectors with $: its arguments are checked against the Variants annotation. See variants for defaults, precedence and fragments.

Fields that start with $

FieldEarlier spellingHolds
$kind$$typethe part's kind: 'view' (the default), 'text', 'image' or 'pressable'
$stylestyleraw style fields for the few properties no token covers
$compose—in a variant rule: fragments or parts whose rules are copied in
$grid, $area—web only: a typed grid and the area a part occupies
$webRules—web only: rules for pseudo-elements and DOM selectors

$kind is fixed for a part: it cannot be set inside a state, condition or variant rule. $webRules takes the result of webRules() and belongs in a web platform block:

field.styles.ts
import { webRules } from '@toned/core'
import { stylesheet } from './system.ts'

export const fieldStyles = stylesheet({
  Label: {
    $kind: 'text',
    opacity: 1,
    '@platform web': {
      // Selector escape hatch: a pseudo-element cannot be a part.
      $webRules: webRules({ '&::after': { content: '" *"' } }),
    },
  },
})

The grid fields are shown working in the grid example and specified in the core reference.

Where a key may appear

PlaceAcceptsIts block holds
Inside a partstates, viewport, container, platform, combined conditionstokens and $style for that part
The sheet rootviewport, container, platform, other parts, combined conditionsa map of parts
A variant rulethe same keys as the sheet root, beside its partsa map of parts
t()states and viewport, one level deeptokens for the element

At the sheet root a bare q.state('hover') has no part to belong to; name the part with q.part('Root').state('hover'). Rules in a sheet apply in the order they are written, and a later matching rule wins.

Earlier spellings

These remain valid, compile to the same rules, and can be mixed with the current forms. The lint rule toned/prefer-canonical-declarations reports style and $$type and can rename them.

EarlierCurrent
'@md''@media md' or q.media('md')
'@card/wide''@container card wide' or q.container('card', 'wide')
'@platform.web''@platform web' or q.platform('web')
$$type$kind
style$style
bp(), cq(), and(), or(), not() from '@toned/core/compat'q.media, q.container, q.all, q.any, q.not
.variants<Mods>($ => …).variants(($: Variants<Mods>) => …)