GrSwitch
Quickly turns a binary setting on and off.
Overview
When to take it
- the setting is switched on at once — notifications, the dark theme, access by link: there is no “Save” button beside it;
- the application goes to the server —
loadingholds the state for the duration of the request, not letting it be toggled twice; - the state matters more than a choice — “on/off” reads faster than a pair of options;
- the value is not a boolean —
valuesets what goes out in the on position.
When to take something else
| Need | Take |
|---|---|
| The value will leave with the submission of a form | GrCheckbox |
| There are two options, but they are display modes | GrSegmented |
| There are more than two options | GrRadioGroup |
| There are several values | GrCheckboxGroup |
Playground controls come from the API, not from hand
Loading…
0 changed· state is in the URL
<GrSwitch>Weekly revenue digest</GrSwitch>Examples 3 each with a live demo and its code
Notifications that apply at once
A switch is for a change that happens immediately — there is no «Save» below.
:model-valuewith@update:model-valuemoves it only when the server agrees; while the request is out,loadingspins in the thumb, blocks a second flip and says what is being saved throughloading-text. The digest fails to save and stays off.label-position="start"puts the text first, the dispute alerts aredisabled.Feature flags per environment
In a column of flags the state has to read on its own:
show-state-textwrites it inside the track. A flag removed in code isreadonly— visible and focusable, never flipped from here. The kill switch is painted withactive-background-color, and the words on it take the matching foreground through--gr-switch-state-text-fg.Flag Staging Production proration-v2New proration for seats added mid-cyclesepa-instantSEPA Instant payoutslegacy-vatOld VAT lookup — removed in 4.2Maintenance modeCheckout shows «Back in a few minutes»; webhooks are queued.
API generated from the package · cannot drift
Props
| Prop | Type | default | Description |
|---|---|---|---|
disabled | boolean | undefined | false | Unavailable: does not toggle and does not go into the form. It adds up by "or" with the field and the form. |
modelValuev-model | boolean | undefined | false | The value of the switch. Optional: without a `v-model` the control is drawn switched off — the same as `GrCheckbox`. A mandatory prop would force a `ref` to be started even where the switch is presentational. |
readonly | boolean | undefined | false | Read only: the state is visible but does not toggle. |
invalid | boolean | undefined | false | The visual and ARIA state of an error. |
required | boolean | undefined | false | A mandatory field (`aria-required`). |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | The size of the track and the thumb: `xs`–`lg`. `md` by default; taken from `GrConfigProvider`. |
ariaLabel | string | undefined | undefined | The accessible name when there is no label either in the slot or on `GrFormField`. |
loading | boolean | undefined | false | Saving is in progress: the thumb shows a spinner, and toggling is blocked. |
loadingText | string | undefined | undefined | i18n: what exactly is loading. `aria-busy` on its own is not announced by part of the assistive technologies. |
name | string | undefined | undefined | The name of the field for the native submission of the form. Without it the hidden field is not rendered. |
value | string | undefined | "on" | The value that goes into the form in the switched-on state. |
form | string | undefined | undefined | The `id` of the form if the switch lies outside it. |
labelPosition | "end" | "start" | undefined | "end" | The side of the label relative to the track. |
activeBackgroundColor | string | undefined | undefined | A custom background colour in the active state. If it is not set — `var(--gr-primary)`. |
inactiveBackgroundColor | string | undefined | undefined | A custom background colour in the inactive state. If it is not set — `var(--gr-muted)`. |
showStateText | boolean | undefined | undefined | The state label inside the track — on the side free of the thumb. It fits only on `md` and `lg`; on the small steps it is not drawn. |
checkedText | string | undefined | undefined | The text of the on state. Without it — `gr.switch.on` from the locale. |
uncheckedText | string | undefined | undefined | The text of the off state. Without it — `gr.switch.off` from the locale. |
autoWidth | boolean | undefined | undefined | The track stretches to the label, and the size step becomes the lower bound of the width. Without `showStateText` it makes no sense: there is nothing to stretch to. |
Slots
| Slot | Type | Description |
|---|---|---|
default | any | The label of the switch. |
checked-icon | any | The sign on the thumb in the on state — a tick, a lock, anything. The size is set by the wrapper from the `size` step, so the content of the slot stretches to it: `class="h-full w-full"`, as in `GrBadge` and `GrChip`. The sign is decorative — the thumb as a whole is `aria-hidden`, and the screen reader takes the state from `aria-checked`. Meaning that exists nowhere else must not be put here. |
unchecked-icon | any | The same for the off state. The slots are independent. |
Events
| Event | Signature | Description |
|---|---|---|
update:modelValue | (event: "update:modelValue", value: boolean): void | — |
change | (event: "change", value: boolean): void | — |
focus | (event: "focus", event: FocusEvent): void | — |
blur | (event: "blur", event: FocusEvent): void | — |
Methods
| Methods | Signature | Description |
|---|---|---|
focus | () => void | Put focus on the switch. |
blur | () => void | Remove focus from the switch. |
Types
Every exported type of the subpath, grouped by the file it lives in. Names, generics and bodies come from the package’s declaration files and cannot drift.
import type { GrSwitchConfigurableProps, GrSwitchSize, GrSwitchLabelPosition } from '@feugene/granularity/components/GrSwitch'Filedist/types/components/GrSwitch/config.d.ts
const grSwitchConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrSwitch">;Filedist/types/components/GrSwitch/defaults.d.ts
interface GrSwitchConfigurableProps {
size: GrSwitchSize;
showStateText: boolean;
autoWidth: boolean;
}Filedist/types/components/GrSwitch/safelist.d.ts
const grSwitchSafelist: string[];Filedist/types/components/GrSwitch/grSwitchStyles.d.ts
type GrSwitchSize = GrComponentSize;type GrSwitchLabelPosition = typeof GR_SWITCH_LABEL_POSITIONS[number];type GrSwitchStateTextSize = typeof GR_SWITCH_STATE_TEXT_SIZES[number];Filedist/types/components/GrSwitch/GrSwitch.vue.d.ts
interface GrSwitchProps {
modelValue?: boolean;
disabled?: boolean;
readonly?: boolean;
invalid?: boolean;
required?: boolean;
ariaLabel?: string;
size?: GrSwitchSize;
labelPosition?: GrSwitchLabelPosition;
loading?: boolean;
loadingText?: string;
name?: string;
value?: string;
form?: string;
activeBackgroundColor?: string;
inactiveBackgroundColor?: string;
showStateText?: boolean;
checkedText?: string;
uncheckedText?: string;
autoWidth?: boolean;
}interface GrSwitchEmits {
(e: 'update:modelValue', value: boolean): void;
(e: 'change', value: boolean): void;
(e: 'focus', event: FocusEvent): void;
(e: 'blur', event: FocusEvent): void;
}Filedist/types/components/GrSwitch/index.d.ts
type GrSwitchInstance = ComponentExposed<typeof GrSwitchComponent>;Accessibility
APG pattern
switch
Keys
Enter
Full contract of the package: docs/keyboard.md ↗
Usage from the library prose
The native form
<GrSwitch v-model="notifications" name="notifications" value="on">
Email notifications
</GrSwitch>The semantics of a checkbox: a switch that is on sends name=value, and one that is off is not
sent at all — the server tells “off” by the absence of the key. Without name there is no hidden
field; form links the switch to a form if it lies outside it.
Interactive content inside a <button> is invalid, so the hidden field is a neighbour of the
button, and the root of the component is a fragment. class and the other attributes still land on
the button.
Loading
<GrSwitch :model-value="backup" :loading="syncing" @change="save" />loading shows a spinner in the thumb, marks the control aria-busy and blocks toggling while the
request is in flight. What exactly is loading is set by loadingText (the gr.switch.loading key
by default): aria-busy on its own is not announced by some screen readers.
The label
The label arrives through the default slot, and labelPosition="start" moves it to the left of the
track. The row is unfolded rather than rearranged in the DOM: a screen reader reads the order of the
nodes, and the track has to stay first.
Without a label the name has to be set another way — ariaLabel or a GrFormField, which will link
a <label for> to the button.
The state label does not live on every step
<GrSwitch v-model="notifications" show-state-text>
Notifications
</GrSwitch>showStateText puts “on/off” inside the track, on the side free of the thumb: the state reads
on its own, without a comparison with the neighbouring switch. The texts come from the locale
(gr.switch.on / gr.switch.off) and are overridden by the checkedText / uncheckedText props.
The label is drawn only at md and lg; at xs and sm it is absent, and in the dev build the
component explains why. It is a matter of geometry: beside the thumb there remain
13 / 17 / 21 / 29px at the xs…lg steps, while a three-letter word takes about 19px even at a
9px type size. The track does not stretch to fit the text in the process — its width is fixed by
the size table, and the travel of the thumb is computed from the same table.
Hence the Russian default too, the two-letter “yes” / three-letter “no”: the Russian word for “off” takes 31px and does not fit at any step. Text of your own is worth measuring the same way — a long one is clipped silently.
For a screen reader the label does not exist at all (aria-hidden): the track lies inside the
<button role="switch">, and the visible text would enter the accessible name — “Wi-Fi ON” instead
of “Wi-Fi”, although the state is already announced through aria-checked.
The colour of the label is the -fg role of the fill it lies on. If you have given the track a
colour of your own through activeBackgroundColor, set the colour of the text as well: the library
will not compute the contrast on an arbitrary fill. Both hooks (the colour and the type size) are in
../tokens.md.
A sign on the thumb
<GrSwitch v-model="autosave">
<template #checked-icon>
<IconCheck class="h-full w-full" />
</template>
<template #unchecked-icon>
<IconX class="h-full w-full" />
</template>
Autosave
</GrSwitch>The checked-icon and unchecked-icon slots put a sign on the thumb itself. The size is held by a
wrapper on the size step, and the content stretches to it (h-full w-full) — the same device as
in GrBadge and GrChip, and for the same reason: the scale of the
sign has to follow the switch rather than the markup at the call site.
The slots are independent: if a sign is needed in the on state only, the second one is simply not passed.
The sign has every step available, including xs and sm, where the state label does not fit into
the track: the sign shrinks without losing recognisability, unlike a three-letter word. Inside the
thumb it is left 8 / 10 / 12 / 16px.
loading displaces the sign. The spinner and the sign occupy the same centre, and while the
request is in flight, “the state is changing” matters more than “what it has become”.
The sign is decorative: the thumb is aria-hidden as a whole, and the screen reader takes the
state from aria-checked. Meaning that exists nowhere else must not be put here.
The colour of the sign and of the spinner is shared, from --gr-switch-thumb-fg. It does not
branch by state and does not dim in disabled: it is the track that is dimmed, while the thumb
stays --gr-card in every state, and content that changed its colour would part ways with its own
backing.
The track stretches only on request
<GrSwitch v-model="access" show-state-text auto-width checked-text="Enabled" unchecked-text="Disabled">
Access by link
</GrSwitch>autoWidth lifts the limit on length: the size step becomes the lower bound of the width, and
beyond it the track grows to fit the label. The height does not change in the process — only the
row stretches.
The default stays fixed on purpose. A row of switches with different labels becomes ragged, and whoever laid out a column for the width of the step would get a skew — that is the consumer’s choice, not the default behaviour.
The width does not twitch on toggling, although “on” and “off” are almost never equal in width: an invisible copy of the opposite text is put into the same cell, and the width is set by the larger of the two.
In both modes the thumb travels without a single number that depends on the width: the pair
left: calc(100% − gap) and translateX(-100%) computes percentages from different elements — the
first from the track, the second from the thumb itself. Stretching therefore needs neither
measurements nor a ResizeObserver, and the travel stays linear: both values animate together.
The states
| Prop | What it does |
|---|---|
disabled | dims the control with the --gr-disabled-bg / -brd / -fg tokens; they override custom track colours as well |
readonly | the state is visible but does not change (aria-readonly) |
invalid, required | aria-invalid / aria-required; they add to the context of GrFormField |
Unavailability is dimmed with a background rather than with opacity: transparency dilutes text
tokens tuned to AA and drops the contrast of the label.
The events and the keyboard
update:modelValue and change are emitted together. The instance gives away focus() and
blur().
The keyboard is native: the control is a real <button>, so Space and Enter toggle it by the
browser’s own means, and Tab walks it in the general order.
Styling
size is xs…lg and is read from GrConfigProvider (componentDefaults.GrSwitch.size). The
colour of the track is set pointwise with the activeBackgroundColor / inactiveBackgroundColor
props.
Props in this example