GrSwitch
Quickly turns a binary setting on and off.
Machine-translated from the Russian original, not yet reviewed. Read the original
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 |
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 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.
Playground 15
Loading…
<GrSwitch />Install
npm i @feugene/granularityImport
import { GrSwitch } from '@feugene/granularity/components/GrSwitch'API
Props
| Prop | Type | default | Description |
|---|---|---|---|
modelValue | 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. |
disabled | boolean | undefined | false | — |
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 | — |
ariaLabel | string | undefined | undefined | — |
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)`. |
Slots
| Slot | Type | Description |
|---|---|---|
default | any | The label of the switch. |
Events
| Event | Type | Description |
|---|---|---|
update:modelValue | [value: boolean] | — |
change | [value: boolean] | — |
focus | [event: FocusEvent] | — |
blur | [event: FocusEvent] | — |
Methods / Expose
| Methods / Expose | Type | Description |
|---|---|---|
focus | () => void | — |
blur | () => void | — |
Examples 4
Interactive switch constructor
<script setup lang="ts">
import { computed, ref } from 'vue'
import {
GrFormField,
GrInput,
GrRadioGroup,
GrSwitch,
type GrSwitchSize,
} from '@feugene/granularity'
import CodeBlock from '../../../components/doc/CodeBlock.vue'
const checked = ref(true)
const disabled = ref(false)
const size = ref<GrSwitchSize>('md')
const label = ref('Email notifications')
const ariaLabel = ref('')
const activeBackgroundColor = ref('')
const inactiveBackgroundColor = ref('')
const sizeOptions = [
{ value: 'xs', label: 'XS' },
{ value: 'sm', label: 'SM' },
{ value: 'md', label: 'MD' },
{ value: 'lg', label: 'LG' },
] satisfies Array<{ value: GrSwitchSize, label: string }>
const switchText = computed(() => {
return label.value.trim() || 'Email notifications'
})
const resolvedAriaLabel = computed(() => {
return ariaLabel.value.trim() || undefined
})
const resolvedActiveBackgroundColor = computed(() => {
return activeBackgroundColor.value.trim() || undefined
})
const resolvedInactiveBackgroundColor = computed(() => {
return inactiveBackgroundColor.value.trim() || undefined
})
const previewSummary = computed(() => {
if (disabled.value)
return 'A disabled switch blocks state changes but keeps the visual context of the current setting.'
if (resolvedActiveBackgroundColor.value || resolvedInactiveBackgroundColor.value) {
return 'Local color overrides help embed the switch into a special scenario without changing global theme tokens.'
}
if (checked.value)
return 'In the enabled state the track uses the primary accent and works well for key feature toggles.'
return 'Pick the size, label and optional accessibility/color props to quickly assemble the switch contract you need.'
})
function escapeAttribute(value: string) {
return value.replaceAll('&', '&').replaceAll('"', '"')
}
const previewCode = computed(() => {
const attributes = [
`:model-value="${checked.value ? 'true' : 'false'}"`,
`size="${size.value}"`,
]
if (disabled.value)
attributes.push('disabled')
if (resolvedAriaLabel.value && resolvedAriaLabel.value !== switchText.value) {
attributes.push(`aria-label="${escapeAttribute(resolvedAriaLabel.value)}"`)
}
if (resolvedActiveBackgroundColor.value) {
attributes.push(`active-background-color="${escapeAttribute(resolvedActiveBackgroundColor.value)}"`)
}
if (resolvedInactiveBackgroundColor.value) {
attributes.push(`inactive-background-color="${escapeAttribute(resolvedInactiveBackgroundColor.value)}"`)
}
return ['<GrSwitch', ...attributes.map(attribute => ` ${attribute}`), '>', ` ${switchText.value}`, '</GrSwitch>'].join('\n')
})
</script>
<template>
<div class="grid gap-4 xl:grid-cols-[minmax(0,1.15fr)_320px]">
<div class="grid gap-4">
<div
class="relative grid min-h-[280px] rounded-[24px] border border-dashed border-[var(--preview-brd)] bg-[image:var(--preview-surface)] p-6 pb-[72px]"
>
<div class="flex h-full flex-col items-center justify-center gap-4 text-center">
<div class="showcase-demo-caption text-xs">
Preview
</div>
<GrSwitch
:model-value="checked"
:disabled="disabled"
:size="size"
:aria-label="resolvedAriaLabel"
:active-background-color="resolvedActiveBackgroundColor"
:inactive-background-color="resolvedInactiveBackgroundColor"
@update:model-value="checked = $event"
>
{{ switchText }}
</GrSwitch>
<div class="pointer-events-none absolute inset-x-6 bottom-6 flex justify-center border-t border-dashed border-[var(--preview-brd)] pt-2">
<div class="showcase-demo-text max-w-[42ch] text-center text-sm">
{{ previewSummary }}
</div>
</div>
</div>
</div>
<CodeBlock :code="previewCode" language="vue" expanded title="Rendered snippet" />
</div>
<div class="showcase-demo-panel grid gap-4 rounded-[28px] border p-4 lg:p-5">
<div class="showcase-demo-title text-sm font-semibold">
Switch properties
</div>
<div class="grid gap-4">
<GrFormField label="Size">
<GrRadioGroup v-model="size" :options="sizeOptions" variant="button" size="sm" />
</GrFormField>
<GrFormField label="Label">
<GrInput
v-model="label"
placeholder="Email notifications"
aria-label="Switch label"
/>
</GrFormField>
<GrFormField label="Accessibility label">
<GrInput
v-model="ariaLabel"
placeholder="Used when the visible label is not enough"
aria-label="Switch accessibility label"
/>
</GrFormField>
<GrFormField label="Active background color">
<GrInput
v-model="activeBackgroundColor"
placeholder="#22c55e / var(--gr-primary)"
aria-label="Switch active background color"
/>
</GrFormField>
<GrFormField label="Inactive background color">
<GrInput
v-model="inactiveBackgroundColor"
placeholder="#e5e7eb / var(--gr-muted)"
aria-label="Switch inactive background color"
/>
</GrFormField>
</div>
<div class="grid gap-3 rounded-2xl border border-[var(--gr-brd)] bg-[var(--gr-card)] p-4">
<GrSwitch v-model="checked" size="sm">
Checked
</GrSwitch>
<GrSwitch v-model="disabled" size="sm">
Disabled
</GrSwitch>
</div>
</div>
</div>
</template>Size scale from compact to prominent
<script setup lang="ts">
import { ref } from 'vue'
import { GrSwitch } from '@feugene/granularity'
const xsValue = ref(false)
const smValue = ref(false)
const mdValue = ref(true)
const lgValue = ref(true)
</script>
<template>
<div class="flex flex-wrap items-center gap-6">
<GrSwitch v-model="xsValue" size="xs">Extra small</GrSwitch>
<GrSwitch v-model="smValue" size="sm">Small</GrSwitch>
<GrSwitch v-model="mdValue" size="md">Medium</GrSwitch>
<GrSwitch v-model="lgValue" size="lg">Large</GrSwitch>
</div>
</template>Labeled switches and disabled state
<script setup lang="ts">
import { ref } from 'vue'
import { GrSwitch } from '@feugene/granularity'
const notifications = ref(true)
const disabled = ref(false)
const syncing = ref(false)
const backup = ref(false)
function saveBackup(value: boolean): void {
syncing.value = true
window.setTimeout(() => {
backup.value = value
syncing.value = false
}, 1200)
}
</script>
<template>
<div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_220px]">
<div class="grid gap-3">
<GrSwitch v-model="notifications" :disabled="disabled">
Email notifications
</GrSwitch>
<GrSwitch :model-value="true" disabled>
Always on
</GrSwitch>
<GrSwitch
:model-value="backup"
:loading="syncing"
label-position="start"
@change="saveBackup"
>
Automatic backup
</GrSwitch>
</div>
<div class="rounded-2xl border border-[var(--gr-brd)] bg-[var(--gr-card)] p-4">
<GrSwitch v-model="disabled" size="sm">
Disable labeled switch
</GrSwitch>
</div>
</div>
</template>Custom active and inactive colors
<script setup lang="ts">
import { ref } from 'vue'
import { GrInput, GrSwitch } from '@feugene/granularity'
const enabled = ref(true)
const activeBackgroundColor = ref('#22c55e')
const inactiveBackgroundColor = ref('#e5e7eb')
</script>
<template>
<div class="grid gap-4">
<GrSwitch
v-model="enabled"
:active-background-color="activeBackgroundColor"
:inactive-background-color="inactiveBackgroundColor"
>
Custom colors
</GrSwitch>
<div class="grid gap-3 md:grid-cols-2">
<GrInput v-model="activeBackgroundColor" placeholder="#22c55e / var(--gr-primary)" />
<GrInput v-model="inactiveBackgroundColor" placeholder="#e5e7eb / var(--gr-muted)" />
</div>
</div>
</template>Accessibility
- APG pattern
switch