CSS Toggles Explainer & Proposal

Table of Contents

Authors

See the References & Acknowledgements for additional contributors and prior art

Participate

Introduction

There are many use-cases where an interaction (click/gesture) on one element toggles a ‘state’ that can be shared with other elements. This can range from toggling light/dark mode, to activating slide-in navigation, opening and closing menus, or interacting with sectioned content as tabs, accordions, or carousels. HTML also provides the <input type=checkbox> element, with a “checked” state that toggles between true and false when the user activates the element, and which can be selected by the :checked pseudoclass.

These cases currently require custom Javascript, which adds a barrier for authors otherwise capable of implementing the visual design – and often results in less performant, less accessible solutions.

We propose generalizing the pattern so that it can be applied to any element using a declarative syntax in CSS, with built-in accessibility and performance.

Goals [or Motivating Use Cases, or Scenarios]

To borrow language from Nicole Sullivan:

a gesture on a trigger causes a state change on a target

Where:

Several of the expected use-cases involve showing and hiding content based on the state of a toggle:

However, there are also use-cases for a toggle to have more complex style impact:

The Open UI project has been working on new elements/attributes that could help with some of these use-cases at a high level – such as panel sets (‘spicy sections’), selectmenus, and popups. It’s our goal that CSS toggles provide some shared functionality that those elements & attributes can rely on, while also giving authors access to define new toggle-based patterns.

Along the way, we have a few goals for how this feature should work:

Non-goals

Managing application state

While there is a much larger need for improved application state management on the web, this proposal is focused specifically on the needs of CSS. That line is not necessarily clean and simple to define – but we think that:

For example, interacting with a ‘tab’ or ‘accordion’ design pattern is a purely presentational concern that requires some state management. However, changing the ‘status’ of a user/page from ‘logged out’ to ‘logged-in’ – or deleting an item from a list – are broader application states that may only be ‘reflected’ in the presentation.

The boundaries are complicated, since it may be useful for eg:

That means we need both:

Potential name confusion

The term ‘toggle’ has been used in reference to various different features over the years.

CSS Values & Units level 5 defines an (unimplemented) toggle() function that allows cycling through a set of values as elements are nested. For example, toggling between italic/normal or cycling through different list markers:

em { font-style: toggle(italic; normal); }
ul { list-style-type: toggle(disc; circle; square; box); }

This proposal is unrelated to that CSS function. (We might want to bikeshed the naming of one or the other). David Baron has proposed cycle() as an alternative name for the toggle() function.

The word is also sometimes used in reference to ‘switch’ components. While this proposal has some overlap with a switch – it could be used to generate the switch behavior – we are not attempting to define a new element here.

It’s also possible that the term ‘toggle’ could cause confusion, as many might expect it to have a boolean (on/off) behavior. The current proposal, however, allows for any number of desired states.

Proposal for declarative CSS toggles

Broad overview

This proposal relies on several primary concepts. First off the toggles themselves:

Once we have toggles, we need a way to access them – both for the sake of changing state, and also in order to use that state somehow. Every toggle has a toggle scope of elements that are able to ‘see’ or alter its state. That scope includes the toggle-root element and any descendants (similar to inheritance), as well as (optionally) siblings and the descendants of siblings (similar to the css counters).

Any element that can ‘see’ and access a given toggle:

It’s important for authors to understand the distinctions between these things:

Establishing toggles (toggle-root)

The toggle-root property generates new toggles on a given element, and controls how those toggles behave. The overall syntax allows either none, or a comma-separated list of toggle definitions.

A simple auto/light/dark color-mode toggle might be defined on the document root:

:root {
/* color-mode toggle with 2 active states */
toggle-root: color-mode 2;
}

Toggle names

Each toggle definition begins with a (required) toggle name – an identifier that allows us to access this particular toggle.

Toggle states

After the name, we can optionally define the initial and known states. Toggle states are represented as either integers or custom idents:

<<toggle-states>> = <<known-states>> [at <<toggle-state>>]?

<dfn><<known-states>></dfn> = <<integer [1, ∞]>> | '[' <<custom-ident>>* ']'
<dfn><<toggle-state>></dfn> = <<integer [0, ∞]>> | <<custom-ident>>

When known states are provided as a list of names, those states can be set & accessed either by name (as specified) or by number (0-indexed list position). Toggles are also allowed to be put in states that are ‘unknown’ above the maximum, or with a name that is not listed.

:root {
/* 2 active states (initially inactive) */
toggle-root: color-mode 2;
/* same result as above, but with explicit initial state */
toggle-root: color-mode 2 at 0;
}

.my-toggle {
/* 4 active states (initially in 2nd active state) */
toggle-root: my-toggle 4 at 2;
}

Named states provide more clarity around the purpose of a state beyond simple numbering:

html {
toggle-root:
colors [auto light dark] at light,
middle-out 10 at 5,
switch /* `1 at 0` is the default */
;
}

Default toggle events & ‘overflow’

While it’s possible to define more complex events to move from one specific state to another – as you might in more complex state machines – many of the known use-cases can be handled with simple increment/decrement events.

To facilitate these simpler use-cases, every toggle has a pre-defined ‘overflow’ behavior when incrementing above, or decrementing below the list of known states:

Unknown states are considered both ‘higher than the maximum’ and ‘lower than the minimum’ so that overflow rules are applied in either direction. Increments cycle to the minimum (0 or 1), while decrements cycle to the maximum state.

Toggle groups and scopes

Finally, there are two optional boolean keywords, for grouping and scoping toggles:

The default ‘wide’ scope is similar to the way CSS counters behave, while the ‘narrow’ (self) scope is more similar to inheritance.

Establishing toggle triggers (toggle-trigger)

Once a toggle has been established, any elements inside the scope of that toggle can be set as ‘triggers’ for that scope, using the toggle-trigger property. Triggers would become activatable in the page (part of the focus order, and able to receive interaction) – so that user-activation of the trigger element increments the state of one or more toggles.

The toggle-trigger property can be set to none, or a comma-separated list of one or more toggle-activation instructions. Each instruction includes:

When a trigger is activated by a user, then for each toggle listed, if a toggle of that name is visible to the trigger, its state is updated according to the specified event (if valid). There are currently several event types provided:

To trigger the color-mode toggle created above, we could add triggers anywhere in the page (since the toggle scope is the entire document in this case). Triggers could either cycle the value, or set it to a particular state. The current spec allows these two formats:

button[toggle-colors] {
/* on activation: increment toggle to next state along default path */
toggle-trigger: color-mode;
}

button[toggle-colors='dark'] {
/* on activation: set toggle to a specific state */
toggle-trigger: color-mode set dark;
/* named states can be referred to by either name or position */
toggle-trigger: color-mode set 2;
}

Increment and decrement events allow us to move in either direction around a slideshow or carousel:

.next {
/* incrementing would be the default */
toggle-trigger: slide;
toggle-trigger: slide next;
}

.previous {
toggle-trigger: slide prev;
}

There is much more to explore along these lines, especially if we want to allow for state-machine ‘transitions’ that define dynamic state changes. I think that would make sense as a level 2 extension of this proposal, and a case that we should consider while designing the syntax.

Combined root and trigger shorthand (toggle)

While it can be useful to have them separate, there are many use-cases where the same element can act as both root and trigger for a toggle. The toggle shorthand property has the same syntax as toggle-root, but establishes the element as both root and trigger.

For example, a definition list:

<dl class='accordion'>
<dt>Term 1</dt>
<dd>Long description......</dd>
<dt>Term 2</dt>
<dd>Another long description.....</dd>
</dl>

Could set each term as both a toggle root and a trigger for its own toggle:

.accordion > dt {
toggle: glossary;
}

Toggling visibility (toggle-visibility)

One of the common use-cases for this feature is the ability to build various types of ‘disclosure widget’ – from tabs and accordions, to popups, and details/summary. It’s essential that we make that both simple to achieve and also accessible by default. In many cases, we want this ‘off-screen’ (temporarily hidden) content to remain available for accessibility features, in-page searching, linking, focus, etc. The toggle-visibility property allows an element to automatically tie its display to the state of a particular toggle.

In addition to changing visibility based on the toggle state, this allows us to change the toggle based on its visibility. If a currently-hidden element becomes ‘relevant to the user’ (through linking, search, etc) then the toggle is set to an active state, and the content is displayed.

The spec currently allows normal (no effect) or toggle <toggle-name> values. Using our definition-list example above, we can add visibility-toggling to the definitions – hidden by default, but connected to toggle state and available when relevant:

.accordion > dt {
toggle: glossary;
}

.accordion > dd {
toggle-visibility: toggle glossary;
}

Selecting based on toggle state (:toggle())

While toggling visibility is common, there are often associated styles based on the same state (e.g. styling the active tab, when its contents are visible) – or toggles that don’t relate to visibility at all. The :toggle() functional pseudo-class allows us to select elements based on the state of a toggle (as long as the element is in that toggle’s scope) and style based on the toggle state.

The function itself requires a toggle-name, and also accepts an optional integer for selecting on specific active states when there are multiple.

With our auto/light/dark mode example, we can apply the color theme on the root element, using either named or numbered states:

html { /* auto settings, using media queries */ }
html:toggle(color-mode 1) {
/* light mode color settings */
}
html:toggle(color-mode dark) {
/* dark mode color settings */
}

We can also add active styling to the triggers:

button[toggle-colors='dark']:toggle(color-mode dark) {
border-color: cyan;
}

Grouping exclusive toggles (toggle-group)

Toggles can also be grouped together using the toggle-group property – in which case only one toggle from the group can be ‘active’ at a time.

This is similar to how radio inputs work in HTML, but would also be useful in describing patterns like tabs or exclusive accordions.

See the ‘tab and accordion toggle-groups’ section for a full example of this use-case.

Javascript API for CSS toggles

It’s important that toggle states are both ‘available to’ JavaScript, and can also be ‘manipulated by’ scripting.

The details still need to be fleshed out, but roughly we would want to:

I expect each state of a toggle would have an optional name and number value exposed – where predefined states have both a name & number, but any trigger-defined states only have one or the other (as defined by the trigger).

Key scenarios

Binary switch

We can recreate the basic behavior of a self-toggling checkbox or switch component:

.switch {
toggle: switch;
}

.switch:toggle(switch) {
/* style the active state */
}

Details and accordion disclosure components

The behavior of a details/summary element can be replicated:

summary {
toggle: details;
}

details > :not(summary) {
toggle-visibility: toggle details;
}

This can be extended to a whole list of elements, to create a non-exclusive accordion:

<dl class='accordion'>
<dt>Term 1</dt>
<dd>Long description......</dd>
<dt>Term 2</dt>
<dd>Another long description.....</dd>
</dl>

Where each term toggles the following definitions:

.accordion > dt {
toggle: glossary;
}

.accordion > dd {
toggle-visibility: toggle glossary;
}

Both of these examples rely on the toggled content following the trigger element. By moving the toggle-root to a wrapping element we can avoid that restriction. With this code, the summary is no longer required to come first:

details {
toggle-root: details;
}

summary {
toggle-trigger: details;
}

details > :not(summary) {
toggle-visibility: toggle details;
}

Color-mode preferences

It is very common for sites to support both light and dark ‘modes’ for a site, and provide a toggle between those modes. Some sites also provide ‘auto’ mode (the result of a user-preference) and/or additional modes like ‘high-contrast’.

This use-case could be handled with a toggle on the root element. In this case I’m using the proposed syntax for named states:

html {
toggle-root: mode [auto light dark];
}

html:toggle(mode light) {
/* colors for light mode */
}

html:toggle(mode dark) {
/* colors for dark mode */
}

.mode-btn {
toggle-trigger: mode;
}

Tree views

A tree view can be created by nesting the accordion/disclosure pattern:

<ul>
<li><a href='#'>home</a></li>
<li>
<button class='tree'>resources</button>
<ul>
<li><a href='#'>articles</a></li>
<li><a href='#'>demos</a></li>
<li>
<button class='tree'>media</button>
<ul>
<li><a href='#'>audio</a></li>
<li><a href='#'>visual</a></li>
</ul>
</li>
</ul>
</li>
</ul>

And applying the show/hide behavior at every level:

.tree {
toggle: tree;
}

.tree + ul {
toggle-visibility: toggle tree;
}

Tab and exclusive-accordion toggle-groups

Given the following HTML (similar to the proposed ‘spicy sections’ element):

<panel-set>
<panel-tab>first tab</panel-tab>
<panel-card>first panel content</panel-card>
<panel-tab>second tab</panel-tab>
<panel-card>second panel content</panel-card>
</panel-set>

We can define the exclusive/grouped behavior using toggles:

panel-set {
/* The common ancestor establishes a group */
toggle-group: tab;
}

panel-tab {
/* Each tab creates a sticky toggle
(so once it’s open, clicking again won’t close it),
opts into the group,
and declares itself a toggle activator */

toggle: tab 1 group sticky;
}

panel-tab:first-of-type {
/* The first tab also sets its initial state
to be active */

toggle: tab 1 at 1 group sticky;
}

panel-tab:toggle(tab) {
/* styling for the active tab */
}

panel-card {
/* card visibility is linked to toggle state */
toggle-visibility: toggle tab;
}

The same CSS works, even if additional wrappers are added around each tab/card pair:

<panel-set>
<panel-wrap>
<panel-tab>first tab</panel-tab>
<panel-card>first panel content</panel-card>
</panel-wrap>
<panel-wrap>
<panel-tab>second tab</panel-tab>
<panel-card>second panel content</panel-card>
</panel-wrap>
</panel-set>

Tabs using table-of-contents code order?

In order to properly layout tabs as a group above the panel contents, it’s common for tab components use a table-of-contents approach to the markup:

<panel-set>
<tab-list>
<panel-tab>first tab</panel-tab>
<panel-tab>second tab</panel-tab>
</tab-list>
<card-list>
<panel-card>first panel content</panel-card>
<panel-card>second panel content</panel-card>
</card-list>
</panel-set>

This complicates things, since we can no longer rely on the flow of toggle-scopes to associate each trigger with an individual iteration of the toggle.

The rough behavior is still possible to achieve, using a single toggle with multiple active states, but it requires somewhat explicit nth-of-type/nth-child selectors:

/* shared toggle with an active state for each tab-panel */
panel-set {
toggle-root: tabs <tab-count> at 1 cycle-on;
}

/* each tab sets an explicit state */
panel-tab:nth-child(<tab-position>) {
toggle-trigger: tabs <tab-position>;
}

/* each panel responds to an explicit state */
panel-card:nth-child(<tab-position>):toggle(tabs <tab-position>) {
display: block;
}

There are several ways we might be able to improve on this. Most important, perhaps, we could consider extending toggle-visibility to accept not only a toggle name, but also a specific active state.

Carousels and slide-shows?

The current spec doesn’t have great support for carousel-like design patterns, but it wouldn’t take much to improve the basics. Let’s imagine the following html structure:

<section>
<article></article>
<article></article>
<article></article>
<article></article>
</section>

We can add a toggle to track the state of the carousel:

section {
/* 4 is the number of slides */
/* sticky behavior starting at 1 ensures a slide is always active */
toggle-root: slides 4 at 1 sticky
}

From here, we’re in a similar situation to the ‘table-of-contents’ example above. Ideally we would want some way to tie article visibility to the specific state of our toggle. Failing that, we can do something like:

/* articles end up on the left */
article {
transform: translateX(var(--x, -100%));
transition: transform 300ms ease-out;
}

/* bring the active slide into view */
article:nth-child(<n>):toggle(slides <n>) {
--x: 0;
}

/* move upcoming slides to the right */
article:nth-child(<n>):toggle(slides <n>) ~ article {
--x: -100%;
}

For navigating the carousel, both pagination controls and ‘next/prev slide’ triggers would be straight-forward. We just need to put them anywhere visible to the section element:

.next-slide {
toggle-trigger: slides next;
}

.prev-slide {
toggle-trigger: slides prev;
}

.to-slide-3 {
toggle-trigger: slides set 3;
}

In many cases, we would also want to control this carousel using scroll, in addition to (or instead of) buttons. That would require further integration with scroll/snapping behavior, which we could consider for level 2 of the spec.

Triggering dynamic transitions

In many state machines, a given active state is able to describe a the named ‘events’ that are available for a trigger. Rather than having the trigger use a pre-defined event, such as prev/next/set, the trigger would choose one of several custom events allowed by the current state.

This may not be required in a first version of CSS toggles, but we should consider how/if the syntax could be extended to support this use-case.

My initial sense is that we could allow this sort of ‘state machine’ to be defined using an at-rule (name TBD):

@machine <machine-name> {
<state-1> {
<event-1>: <target-state>;
<event-2>: <target-state>;
}
<state-2> {
<event-1>: <target-state>;
<event-2>: <target-state>;
}
}

When creating a new toggle, it could be based on one of these named machines (syntax TBD):

html {
toggle-root: my-toggle machine(<machine-name>);
}

We could consider adding an setting at the toggle-root level to either enforce that all triggers use named transitions (strict), or optionally allow triggers to choose between states, transitions, and default incrementing:

html {
toggle-root: my-toggle machine(<machine-name>, strict);
}

On the trigger side, we would need a syntax that clearly references a custom event name, rather than a pre-defined event. In this example, I use event as the keyword – actual syntax TBD:

.save {
/* trigger a named event, that defines target state */
toggle-trigger: my-toggle event save;
}

As an example, Adam Argyle posted this state machine diagram:

fetch machine with initial state of idle, and fetch transition to loading, then resolve transition to success or reject transition to failure, with a retry transition that returns to loading

We could establish that as a machine for CSS toggles:

@machine fetch {
idle {
try: loading;
}
loading {
resolve: success;
reject: failure;
}
failure {
try: loading;
}
/* as a final state, 'success' does not have transitions */
}

I’ve reused the try transition name in place of fetch and retry, so that a single trigger can activate either transition, as long as the machine is either in a ‘idle’ or ‘failure’ state:

form {
/* maybe name can be optionally implied by machine()? */
toggle-root: machine(fetch);
}

.try {
toggle-trigger: fetch event try;
}

Allow trigger-defined/unknown states?

Triggers can define arbitrary transitions between states, and are also able to define new states:

html {
/* no states defined */
toggle-root: page;
}

button.save {
/* triggers can define arbitrary states */
toggle-trigger: page set saving;
}

New states defined by a trigger do not have any number/name association, and fall ‘outside’ the default cycle behavior. They are considered active states, but ‘above the maximum’ for the sake of incrementing, and ‘below the minimum’ for the sake of decrementing.

Detailed Design Discussion

Avoiding recursive behavior with toggle selectors

In order to allow selector access to toggles (using the :toggle() functional pseudo-class) without causing recursive behavior, toggles exist and persist as independent state on a given element – unaffected by any CSS properties.

CSS properties can only:

Accessibility implications

Interaction between scrolling, gestures, toggles?

For the carousel, and other design patterns, it would be useful to have a two-way integration between toggles and scrolling/scroll-snapping, so that:

There are several other important interactions with related features that we need to keep in mind as well (some may require additional research):

We are currently considering explicit activations to be a planned extension for level 2 of the spec – but it’s possible some of these interactions will need to be solved in level 1.

Considered Alternatives

Previous CSS toggle states proposal

Declarative show-hide explainer

Stakeholder Feedback / Opposition

No known opposition.

References & Acknowledgements

This proposal was heavily influenced by the ‘Declarative Show/Hide’ work of Robert Flack, Nicole Sullivan, and others:

There is also a previous draft spec written by Tab Atkins Jr.: