Skip to content

Switch

A control that toggles between two states.

Demo

This requires the following components to be installed:

This requires the following composables to be installed:

This requires the following theme to be installed:

Component

Switch.vue
vue
<script lang="ts">
import type { SwitchRootProps } from 'reka-ui'
import type { VariantProps } from 'tailwind-variants'
import Icon from '@/ui/components/Icon.vue'
import { useFormField } from '@/ui/composables/useFormField'
import { loadingIcon } from '@/ui/icons'
import theme from '@/ui/theme/switch'
import { reactivePick } from '@vueuse/shared'
import { Label, Primitive, SwitchRoot, SwitchThumb, useForwardProps } from 'reka-ui'
import { tv } from 'tailwind-variants'
import { computed, useId } from 'vue'

const switchTv = tv(theme)

type SwitchVariants = VariantProps<typeof switchTv>

export interface SwitchProps extends Pick<SwitchRootProps, 'disabled' | 'id' | 'name' | 'required' | 'value' | 'defaultValue'> {
  as?: any
  color?: SwitchVariants['color']
  size?: SwitchVariants['size']
  loading?: boolean
  checkedIcon?: string
  uncheckedIcon?: string
  label?: string
  description?: string
  class?: any
  ui?: Partial<typeof switchTv.slots>
}

export interface SwitchEmits {
  change: [payload: Event]
}

export interface SwitchSlots {
  label: (props: { label?: string }) => any
  description: (props: { description?: string }) => any
}
</script>

<script setup lang="ts">
const props = defineProps<SwitchProps>()
const emits = defineEmits<SwitchEmits>()
const slots = defineSlots<SwitchSlots>()

const modelValue = defineModel<boolean>({ default: undefined })

const rootProps = useForwardProps(reactivePick(props, 'required', 'value', 'defaultValue'))

const { id: _id, size, color, name, disabled, ariaAttrs } = useFormField<SwitchProps>(props)
const id = _id.value ?? useId()

const ui = computed(() => switchTv({
  size: size.value,
  color: color.value,
  loading: props.loading,
  required: props.required,
  disabled: disabled.value || props.loading,
}))

function onUpdate(value: any) {
  // @ts-expect-error - 'target' does not exist in type 'EventInit'
  const event = new Event('change', { target: { value } })
  emits('change', event)
}
</script>

<template>
  <Primitive :as="as" :class="ui.root({ class: [props.class, props.ui?.root] })">
    <div :class="ui.container({ class: props.ui?.container })">
      <SwitchRoot
        :id="id"
        v-bind="{ ...rootProps, ...ariaAttrs }"
        v-model="modelValue"
        :name="name"
        :disabled="disabled || loading"
        :class="ui.base({ class: props.ui?.base })"
        @update:model-value="onUpdate"
      >
        <SwitchThumb :class="ui.thumb({ class: props.ui?.thumb })">
          <Icon v-if="loading" :name="loadingIcon" :class="ui.icon({ class: props.ui?.icon, checked: true, unchecked: true })" />
          <template v-else>
            <Icon v-if="checkedIcon" :name="checkedIcon" :class="ui.icon({ class: props.ui?.icon, checked: true })" />
            <Icon v-if="uncheckedIcon" :name="uncheckedIcon" :class="ui.icon({ class: props.ui?.icon, unchecked: true })" />
          </template>
        </SwitchThumb>
      </SwitchRoot>
    </div>
    <div v-if="(label || !!slots.label) || (description || !!slots.description)" :class="ui.wrapper({ class: props.ui?.wrapper })">
      <Label v-if="label || !!slots.label" :for="id" :class="ui.label({ class: props.ui?.label })">
        <slot name="label" :label="label">
          {{ label }}
        </slot>
      </Label>
      <p v-if="description || !!slots.description" :class="ui.description({ class: props.ui?.description })">
        <slot name="description" :description="description">
          {{ description }}
        </slot>
      </p>
    </div>
  </Primitive>
</template>
Switch.vue
vue
<script lang="ts">
import type { SwitchRootProps } from 'reka-ui'
import type { VariantProps } from 'tailwind-variants'
import Icon from '@/UI/Components/Icon.vue'
import { useFormField } from '@/UI/Composables/useFormField'
import { loadingIcon } from '@/UI/icons'
import theme from '@/UI/Theme/switch'
import { reactivePick } from '@vueuse/shared'
import { Label, Primitive, SwitchRoot, SwitchThumb, useForwardProps } from 'reka-ui'
import { tv } from 'tailwind-variants'
import { computed, useId } from 'vue'

const switchTv = tv(theme)

type SwitchVariants = VariantProps<typeof switchTv>

export interface SwitchProps extends Pick<SwitchRootProps, 'disabled' | 'id' | 'name' | 'required' | 'value' | 'defaultValue'> {
  as?: any
  color?: SwitchVariants['color']
  size?: SwitchVariants['size']
  loading?: boolean
  checkedIcon?: string
  uncheckedIcon?: string
  label?: string
  description?: string
  class?: any
  ui?: Partial<typeof switchTv.slots>
}

export interface SwitchEmits {
  change: [payload: Event]
}

export interface SwitchSlots {
  label: (props: { label?: string }) => any
  description: (props: { description?: string }) => any
}
</script>

<script setup lang="ts">
const props = defineProps<SwitchProps>()
const emits = defineEmits<SwitchEmits>()
const slots = defineSlots<SwitchSlots>()

const modelValue = defineModel<boolean>({ default: undefined })

const rootProps = useForwardProps(reactivePick(props, 'required', 'value', 'defaultValue'))

const { id: _id, size, color, name, disabled, ariaAttrs } = useFormField<SwitchProps>(props)
const id = _id.value ?? useId()

const ui = computed(() => switchTv({
  size: size.value,
  color: color.value,
  loading: props.loading,
  required: props.required,
  disabled: disabled.value || props.loading,
}))

function onUpdate(value: any) {
  // @ts-expect-error - 'target' does not exist in type 'EventInit'
  const event = new Event('change', { target: { value } })
  emits('change', event)
}
</script>

<template>
  <Primitive :as="as" :class="ui.root({ class: [props.class, props.ui?.root] })">
    <div :class="ui.container({ class: props.ui?.container })">
      <SwitchRoot
        :id="id"
        v-bind="{ ...rootProps, ...ariaAttrs }"
        v-model="modelValue"
        :name="name"
        :disabled="disabled || loading"
        :class="ui.base({ class: props.ui?.base })"
        @update:model-value="onUpdate"
      >
        <SwitchThumb :class="ui.thumb({ class: props.ui?.thumb })">
          <Icon v-if="loading" :name="loadingIcon" :class="ui.icon({ class: props.ui?.icon, checked: true, unchecked: true })" />
          <template v-else>
            <Icon v-if="checkedIcon" :name="checkedIcon" :class="ui.icon({ class: props.ui?.icon, checked: true })" />
            <Icon v-if="uncheckedIcon" :name="uncheckedIcon" :class="ui.icon({ class: props.ui?.icon, unchecked: true })" />
          </template>
        </SwitchThumb>
      </SwitchRoot>
    </div>
    <div v-if="(label || !!slots.label) || (description || !!slots.description)" :class="ui.wrapper({ class: props.ui?.wrapper })">
      <Label v-if="label || !!slots.label" :for="id" :class="ui.label({ class: props.ui?.label })">
        <slot name="label" :label="label">
          {{ label }}
        </slot>
      </Label>
      <p v-if="description || !!slots.description" :class="ui.description({ class: props.ui?.description })">
        <slot name="description" :description="description">
          {{ description }}
        </slot>
      </p>
    </div>
  </Primitive>
</template>

Theme

switch.ts
ts
export default {
  slots: {
    root: '',
    base: '',
    container: '',
    thumb: '',
    icon: '',
    wrapper: '',
    label: '',
    description: '',
  },
  variants: {
    color: {
      primary: {
        base: '',
        icon: '',
      },
      secondary: {
        base: '',
        icon: '',
      },
      success: {
        base: '',
        icon: '',
      },
      info: {
        base: '',
        icon: '',
      },
      warning: {
        base: '',
        icon: '',
      },
      error: {
        base: '',
        icon: '',
      },
      neutral: {
        base: '',
        icon: '',
      },
    },
    size: {
      xs: {
        base: '',
        container: '',
        thumb: '',
        wrapper: '',
      },
      sm: {
        base: '',
        container: '',
        thumb: '',
        wrapper: '',
      },
      md: {
        base: '',
        container: '',
        thumb: '',
        wrapper: '',
      },
      lg: {
        base: '',
        container: '',
        thumb: '',
        wrapper: '',
      },
      xl: {
        base: '',
        container: '',
        thumb: '',
        wrapper: '',
      },
    },
    checked: {
      true: {
        icon: '',
      },
    },
    unchecked: {
      true: {
        icon: '',
      },
    },
    loading: {
      true: {
        icon: '',
      },
    },
    required: {
      true: {
        label: '',
      },
    },
    disabled: {
      true: {
        base: '',
        label: '',
        description: '',
      },
    },
  },
  compoundVariants: [],
  defaultVariants: {
    color: 'primary',
    size: 'md',
  } as const,
}
View Nuxt UI theme
switch.ts
ts
export default {
  slots: {
    root: 'relative flex items-start',
    base: 'inline-flex items-center shrink-0 rounded-full border-2 border-transparent focus-visible:outline-2 focus-visible:outline-offset-2 data-[state=unchecked]:bg-accented transition-colors duration-200',
    container: 'flex items-center',
    thumb: 'group pointer-events-none rounded-full bg-default shadow-lg ring-0 transition-transform duration-200 data-[state=unchecked]:translate-x-0 data-[state=unchecked]:rtl:-translate-x-0 flex items-center justify-center',
    icon: 'absolute shrink-0 group-data-[state=unchecked]:text-dimmed opacity-0 size-10/12 transition-[color,opacity] duration-200',
    wrapper: 'ms-2',
    label: 'block font-medium text-default',
    description: 'text-muted',
  },
  variants: {
    color: {
      primary: {
        base: 'data-[state=checked]:bg-primary focus-visible:outline-primary',
        icon: 'group-data-[state=checked]:text-primary',
      },
      secondary: {
        base: 'data-[state=checked]:bg-secondary focus-visible:outline-secondary',
        icon: 'group-data-[state=checked]:text-secondary',
      },
      success: {
        base: 'data-[state=checked]:bg-success focus-visible:outline-success',
        icon: 'group-data-[state=checked]:text-success',
      },
      info: {
        base: 'data-[state=checked]:bg-info focus-visible:outline-info',
        icon: 'group-data-[state=checked]:text-info',
      },
      warning: {
        base: 'data-[state=checked]:bg-warning focus-visible:outline-warning',
        icon: 'group-data-[state=checked]:text-warning',
      },
      error: {
        base: 'data-[state=checked]:bg-error focus-visible:outline-error',
        icon: 'group-data-[state=checked]:text-error',
      },
      neutral: {
        base: 'data-[state=checked]:bg-inverted focus-visible:outline-inverted',
        icon: 'group-data-[state=checked]:text-highlighted',
      },
    },
    size: {
      xs: {
        base: 'w-7',
        container: 'h-4',
        thumb: 'size-3 data-[state=checked]:translate-x-3 data-[state=checked]:rtl:-translate-x-3',
        wrapper: 'text-xs',
      },
      sm: {
        base: 'w-8',
        container: 'h-4',
        thumb: 'size-3.5 data-[state=checked]:translate-x-3.5 data-[state=checked]:rtl:-translate-x-3.5',
        wrapper: 'text-xs',
      },
      md: {
        base: 'w-9',
        container: 'h-5',
        thumb: 'size-4 data-[state=checked]:translate-x-4 data-[state=checked]:rtl:-translate-x-4',
        wrapper: 'text-sm',
      },
      lg: {
        base: 'w-10',
        container: 'h-5',
        thumb: 'size-4.5 data-[state=checked]:translate-x-4.5 data-[state=checked]:rtl:-translate-x-4.5',
        wrapper: 'text-sm',
      },
      xl: {
        base: 'w-11',
        container: 'h-6',
        thumb: 'size-5 data-[state=checked]:translate-x-5 data-[state=checked]:rtl:-translate-x-5',
        wrapper: 'text-base',
      },
    },
    checked: {
      true: {
        icon: 'group-data-[state=checked]:opacity-100',
      },
    },
    unchecked: {
      true: {
        icon: 'group-data-[state=unchecked]:opacity-100',
      },
    },
    loading: {
      true: {
        icon: 'animate-spin',
      },
    },
    required: {
      true: {
        label: 'after:content-[\'*\'] after:ms-0.5 after:text-error',
      },
    },
    disabled: {
      true: {
        base: 'cursor-not-allowed opacity-75',
        label: 'cursor-not-allowed opacity-75',
        description: 'cursor-not-allowed opacity-75',
      },
    },
  },
  compoundVariants: [
    {
      color: 'secondary',
      class: 'data-[state=checked]:bg-secondary focus-visible:outline-secondary',
    } as const,
    {
      color: 'success',
      class: 'data-[state=checked]:bg-success focus-visible:outline-success',
    } as const,
    {
      color: 'info',
      class: 'data-[state=checked]:bg-info focus-visible:outline-info',
    } as const,
    {
      color: 'warning',
      class: 'data-[state=checked]:bg-warning focus-visible:outline-warning',
    } as const,
    {
      color: 'error',
      class: 'data-[state=checked]:bg-error focus-visible:outline-error',
    } as const,
  ],
  defaultVariants: {
    color: 'primary',
    size: 'md',
  } as const,
}

Test

To test this component, you can use the following test file:

Switch.test.ts
ts
import type { RenderOptions } from '@testing-library/vue'
import Switch from '@/ui/components/Switch.vue'
import theme from '@/ui/theme/switch'
import { fireEvent, render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'

describe('switch', () => {
  const sizes = Object.keys(theme.variants.size) as any

  it.each<[string, RenderOptions<typeof Switch>]>([
    // Props
    ['with modelValue', { props: { modelValue: true } }],
    ['with defaultValue', { props: { defaultValue: true } }],
    ['with id', { props: { id: 'id' } }],
    ['with name', { props: { name: 'name' } }],
    ['with value', { props: { value: 'value' } }],
    ['with disabled', { props: { disabled: true } }],
    ['with checkedIcon', { props: { checkedIcon: 'i-lucide-check', defaultValue: true } }],
    ['with uncheckedIcon', { props: { uncheckedIcon: 'i-lucide-x' } }],
    ['with loading', { props: { loading: true } }],
    ['with loadingIcon', { props: { loading: true, loadingIcon: 'i-lucide-sparkles' } }],
    ['with label', { props: { label: 'Label' } }],
    ['with required', { props: { label: 'Label', required: true } }],
    ['with description', { props: { label: 'Label', description: 'Description' } }],
    ...sizes.map((size: string) => [`with size ${size}`, { props: { size } }]),
    ['with color neutral', { props: { color: 'neutral', defaultValue: true } }],
    ['with as', { props: { as: 'section' } }],
    ['with class', { props: { class: 'inline-flex' } }],
    ['with ui', { props: { ui: { wrapper: 'ms-4' } } }],
    // Slots
    ['with label slot', { slots: { label: () => 'Label slot' } }],
    ['with description slot', { slots: { label: () => 'Description slot' } }],
  ])('renders %s correctly', (name, options) => {
    render(Switch, { attrs: { 'data-testid': 'switch' }, ...options })

    expect(screen.getByTestId('switch')).toMatchSnapshot()
  })

  describe('emits', () => {
    it('update:modelValue event', async () => {
      const { emitted } = render(Switch)

      await fireEvent.click(screen.getByRole('switch'))

      expect(emitted()['update:modelValue']).toMatchInlineSnapshot(`
        [
          [
            true,
          ],
        ]
      `)
    })

    it('change event', async () => {
      const { emitted } = render(Switch)

      await fireEvent.click(screen.getByRole('switch'))

      expect(emitted().change).toMatchInlineSnapshot(`
        [
          [
            Event {
              "isTrusted": false,
            },
          ],
        ]
      `)
    })
  })
})
Switch.test.ts
ts
import type { RenderOptions } from '@testing-library/vue'
import Switch from '@/UI/Components/Switch.vue'
import theme from '@/UI/Theme/switch'
import { fireEvent, render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'

describe('switch', () => {
  const sizes = Object.keys(theme.variants.size) as any

  it.each<[string, RenderOptions<typeof Switch>]>([
    // Props
    ['with modelValue', { props: { modelValue: true } }],
    ['with defaultValue', { props: { defaultValue: true } }],
    ['with id', { props: { id: 'id' } }],
    ['with name', { props: { name: 'name' } }],
    ['with value', { props: { value: 'value' } }],
    ['with disabled', { props: { disabled: true } }],
    ['with checkedIcon', { props: { checkedIcon: 'i-lucide-check', defaultValue: true } }],
    ['with uncheckedIcon', { props: { uncheckedIcon: 'i-lucide-x' } }],
    ['with loading', { props: { loading: true } }],
    ['with loadingIcon', { props: { loading: true, loadingIcon: 'i-lucide-sparkles' } }],
    ['with label', { props: { label: 'Label' } }],
    ['with required', { props: { label: 'Label', required: true } }],
    ['with description', { props: { label: 'Label', description: 'Description' } }],
    ...sizes.map((size: string) => [`with size ${size}`, { props: { size } }]),
    ['with color neutral', { props: { color: 'neutral', defaultValue: true } }],
    ['with as', { props: { as: 'section' } }],
    ['with class', { props: { class: 'inline-flex' } }],
    ['with ui', { props: { ui: { wrapper: 'ms-4' } } }],
    // Slots
    ['with label slot', { slots: { label: () => 'Label slot' } }],
    ['with description slot', { slots: { label: () => 'Description slot' } }],
  ])('renders %s correctly', (name, options) => {
    render(Switch, { attrs: { 'data-testid': 'switch' }, ...options })

    expect(screen.getByTestId('switch')).toMatchSnapshot()
  })

  describe('emits', () => {
    it('update:modelValue event', async () => {
      const { emitted } = render(Switch)

      await fireEvent.click(screen.getByRole('switch'))

      expect(emitted()['update:modelValue']).toMatchInlineSnapshot(`
        [
          [
            true,
          ],
        ]
      `)
    })

    it('change event', async () => {
      const { emitted } = render(Switch)

      await fireEvent.click(screen.getByRole('switch'))

      expect(emitted().change).toMatchInlineSnapshot(`
        [
          [
            Event {
              "isTrusted": false,
            },
          ],
        ]
      `)
    })
  })
})

Contributors

barbapapazes

Changelog

6089f - feat: colors (#154) on 2/14/2025
7a9f6 - feat: attrs on form elements (#150) on 2/14/2025
f87a2 - fix: switch icons on 1/10/2025
c615c - feat: add custom eslint rule to disallow relative imports (#81) on 1/7/2025
7d577 - feat: add toast component (#77) on 1/3/2025
0c429 - feat: add switch component (#59) on 12/18/2024