GrSwitch

  • core
  • forms
  • API · 19 props
  • ⌨ keys

Quickly turns a binary setting on and off.

import { GrSwitch } from '@feugene/granularity/components/GrSwitch'

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 — loading holds 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 — value sets what goes out in the on position.

When to take something else

NeedTake
The value will leave with the submission of a formGrCheckbox
There are two options, but they are display modesGrSegmented
There are more than two optionsGrRadioGroup
There are several valuesGrCheckboxGroup

Playground controls come from the API, not from hand

Stage

Loading…

0 changed· state is in the URL

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-value with @update:model-value moves it only when the server agrees; while the request is out, loading spins in the thumb, blocks a second flip and says what is being saved through loading-text. The digest fails to save and stays off. label-position="start" puts the text first, the dispute alerts are disabled.

  • Feature flags per environment

    In a column of flags the state has to read on its own: show-state-text writes it inside the track. A flag removed in code is readonly — visible and focusable, never flipped from here. The kill switch is painted with active-background-color, and the words on it take the matching foreground through --gr-switch-state-text-fg.

    FlagStagingProduction
    proration-v2New proration for seats added mid-cycle
    sepa-instantSEPA Instant payouts
    legacy-vatOld VAT lookup — removed in 4.2
    Maintenance modeCheckout shows «Back in a few minutes»; webhooks are queued.
  • A report shared by link

    auto-width lets the track grow under «Public» and «Private» without jumping between them, and the checked-icon and unchecked-icon slots put a mark on the thumb. The switch sits outside the form, so form ties it in; a switch posts name=value when on and nothing when off.

    Revenue · Q3 2026Owner: Mina Ortiz
    Only members of the workspace can open the report.

API generated from the package · cannot drift

Props

PropTypedefaultDescription
disabledboolean | undefinedfalseUnavailable: does not toggle and does not go into the form. It adds up by "or" with the field and the form.
modelValuev-modelboolean | undefinedfalseThe 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.
readonlyboolean | undefinedfalseRead only: the state is visible but does not toggle.
invalidboolean | undefinedfalseThe visual and ARIA state of an error.
requiredboolean | undefinedfalseA mandatory field (`aria-required`).
size"xs" | "sm" | "md" | "lg" | undefinedundefinedThe size of the track and the thumb: `xs`–`lg`. `md` by default; taken from `GrConfigProvider`.
ariaLabelstring | undefinedundefinedThe accessible name when there is no label either in the slot or on `GrFormField`.
loadingboolean | undefinedfalseSaving is in progress: the thumb shows a spinner, and toggling is blocked.
loadingTextstring | undefinedundefinedi18n: what exactly is loading. `aria-busy` on its own is not announced by part of the assistive technologies.
namestring | undefinedundefinedThe name of the field for the native submission of the form. Without it the hidden field is not rendered.
valuestring | undefined"on"The value that goes into the form in the switched-on state.
formstring | undefinedundefinedThe `id` of the form if the switch lies outside it.
labelPosition"end" | "start" | undefined"end"The side of the label relative to the track.
activeBackgroundColorstring | undefinedundefinedA custom background colour in the active state. If it is not set — `var(--gr-primary)`.

Slots

SlotTypeDescription
defaultanyThe label of the switch.
checked-iconanyThe 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-iconanyThe same for the off state. The slots are independent.

Events

EventSignatureDescription
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

MethodsSignatureDescription
focus() => voidPut focus on the switch.
blur() => voidRemove 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
import type { GrSwitchConfigurableProps, GrSwitchSize, GrSwitchLabelPosition } from '@feugene/granularity/components/GrSwitch'

Filedist/types/components/GrSwitch/config.d.ts

grSwitchConfigconst
const grSwitchConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrSwitch">;

Filedist/types/components/GrSwitch/defaults.d.ts

GrSwitchConfigurablePropsinterface
interface GrSwitchConfigurableProps {
    size: GrSwitchSize;
    showStateText: boolean;
    autoWidth: boolean;
}

Filedist/types/components/GrSwitch/safelist.d.ts

grSwitchSafelistconst
const grSwitchSafelist: string[];

Filedist/types/components/GrSwitch/grSwitchStyles.d.ts

GrSwitchSizetype
type GrSwitchSize = GrComponentSize;
GrSwitchLabelPositiontype
type GrSwitchLabelPosition = typeof GR_SWITCH_LABEL_POSITIONS[number];
GrSwitchStateTextSizetype
type GrSwitchStateTextSize = typeof GR_SWITCH_STATE_TEXT_SIZES[number];

Filedist/types/components/GrSwitch/GrSwitch.vue.d.ts

GrSwitchPropsinterface
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;
}
GrSwitchEmitsinterface
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

GrSwitchInstancetype
type GrSwitchInstance = ComponentExposed<typeof GrSwitchComponent>;

Accessibility

APG pattern

switch

Keys

Enter

Usage from the library prose

The native form
vue
<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
vue
<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
vue
<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
vue
<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
vue
<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
PropWhat it does
disableddims the control with the --gr-disabled-bg / -brd / -fg tokens; they override custom track colours as well
readonlythe state is visible but does not change (aria-readonly)
invalid, requiredaria-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.