Skip to content

Guides

Interactive Styles

Toned supports hover, focus, and active states using colon-prefixed keys -- inside a stylesheet element definition, or inline with t. On the web they compile to CSS at build time and need no JavaScript event listeners.

Element-Level Pseudo-Classes

Add :hover, :focus, or :active keys inside an element definition:

TypeScript
const buttonStyles = stylesheet({
  Root: {
    $kind: 'pressable',
    bgColor: 'action',
    borderRadius: 'medium',
    cursor: 'pointer',

    ':hover': {
      bgColor: 'action_secondary',
    },

    ':active': {
      bgColor: 'muted',
    },
  },
  Label: {
    $kind: 'text',
    textColor: 'on_action',
  },
})

':focus-visible' and ':focus-within' are written the same way, as are states the system declares itself, such as ':open'. Conditions and selectors lists them with their builder forms.

Cross-Element Selectors

To change one element's styles when a different element is interacted with, use a 'Part:state' key at the stylesheet root level:

TypeScript
const cardStyles = stylesheet({
  Root: {
    $kind: 'pressable',
    bgColor: 'elevated',
    borderRadius: 'large',
    cursor: 'pointer',
  },
  Label: {
    $kind: 'text',
    textColor: 'default',
  },
  Icon: {
    $kind: 'text',
    textColor: 'muted',
  },

  // When Root is hovered, change styles on multiple parts
  'Root:hover': {
    Root: { shadow: 'medium' },
    Label: { textColor: 'action' },
    Icon: { textColor: 'action' },
  },
})

You can combine multiple pseudo-states in a cross-element selector:

TypeScript
'Root:active:hover': {
  Icon: { textColor: 'on_action' },
}

Combining with Variants

Pseudo-classes work inside variant blocks, so different variants can define different interactive behaviour:

TypeScript
import type { Variants } from '@toned/core'

const buttonStyles = stylesheet({
  Root: { $kind: 'pressable', bgColor: 'action', borderRadius: 'medium' },
  Label: { $kind: 'text', textColor: 'on_action' },
}).variants(($: Variants<{
  variant: 'accent' | 'danger'
}>) => ({
  [$.variant('accent')]: {
    Root: {
      ':hover': { bgColor: 'action_secondary' },
    },
  },
  [$.variant('danger')]: {
    Root: {
      bgColor: 'destructive',
      ':hover': { shadow: 'medium' },
    },
    Label: { textColor: 'on_destructive' },
  },
}))

Combining with Breakpoints

Pseudo-classes and breakpoints compose naturally:

TypeScript
const navStyles = stylesheet({
  Link: {
    $kind: 'text',
    textColor: 'muted',
    paddingX: 2,

    ':hover': {
      textColor: 'action',
    },

    '@media md': {
      paddingX: 4,
    },
  },
})

Inline Interactive Styles

The t utility accepts the same colon-prefixed keys, so a one-off element can be interactive without a stylesheet:

TypeScript
import { t } from '@toned/systems/base'

function Tag() {
  return (
    <span
      {...t({
        bgColor: 'default',
        paddingX: 2,
        ':hover': { bgColor: 'action' },
        '@media md': { paddingX: 4 },
      })}
    />
  )
}

Cross-element relationships and variant selection in the examples above require a stylesheet. t is a compatibility helper that reads the installed configuration; its state and @ blocks compile to the custom property chains described below, so they need web output with CSS states and media enabled. Otherwise the block is dropped and the base token value still applies. New integrations use stylesheets with an explicit renderer.

React Native

React Native does not have CSS pseudo-classes. On native platforms, interactive states are handled through React Native's Pressable component. The same :hover and :active keys work in both environments when they are declared in a stylesheet and read with useStyles -- the runtime behaviour adapts to each platform's capabilities.

Inline t blocks are the exception. They have no native equivalent, since they rely on CSS custom properties, so on React Native they are dropped and the style degrades to its non-interactive base. Use a stylesheet with a registered host for anything interactive that has to run on native.

Advanced: How It Works

On the web, interactive styles use the CSS "space toggle" technique. The system declares a custom property for each pseudo-state. A system with an id prefixes these names with it, as in --app-toned_hover:

TypeScript
html {
  --toned_hover: initial;   /* "off" */
  --toned_focus: initial;
  --toned_active: initial;
}

When an element is hovered on a device that can hover, a cascade rule flips the variable from initial (off) to an empty value (on):

CSS
@media (hover: hover) {
  /* Activate on the hovered element */
  ._:hover { --toned_hover: ; }

  /* Reset for children so hover doesn't leak down */
  ._:hover ._ { --toned_hover: initial; }

  /* Re-activate for nested elements that are themselves hovered */
  ._:hover ._:hover { --toned_hover: ; }
}

Token values then reference this variable in a var() fallback chain. When the variable is initial, the fallback (base value) is used. When it is empty, the hover value takes effect. This is the same mechanism that powers responsive breakpoints with --media-md, just triggered by CSS pseudo-classes instead of @media queries.

Inline t styles emit the identical chain, just as element style properties rather than a generated class. A style carrying both a breakpoint and a pseudo override resolves to a single nested chain, with the interaction outermost so it wins: padding: var(--toned_hover__padding, var(--media-md__padding, 8px)).