GrSwitch

Package: @feugene/granularitycoreGroup: forms

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 serverloading 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 booleanvalue 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

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

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 xslg and is read from GrConfigProvider (componentDefaults.GrSwitch.size). The colour of the track is set pointwise with the activeBackgroundColor / inactiveBackgroundColor props.

Playground 15

Loading…

Code
<GrSwitch />

Install

npm i @feugene/granularity

Import

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

API

Props

PropTypedefaultDescription
modelValueboolean | 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.
disabledboolean | undefinedfalse
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" | undefinedundefined
ariaLabelstring | undefinedundefined
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)`.
inactiveBackgroundColorstring | undefinedundefinedA custom background colour in the inactive state. If it is not set — `var(--gr-muted)`.

Slots

SlotTypeDescription
defaultanyThe label of the switch.

Events

EventTypeDescription
update:modelValue[value: boolean]
change[value: boolean]
focus[event: FocusEvent]
blur[event: FocusEvent]

Methods / Expose

Methods / ExposeTypeDescription
focus() => void
blur() => void

Examples 4

Interactive switch constructor

Builderdepends on the showcase environment
<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('&', '&amp;').replaceAll('"', '&quot;')
}

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

Sizes
<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

Disabled
<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

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

Full keyboard contract of the package

Component documentationAll components