Skip to content
touchcn
Esc
navigateopen⌘Jpreview
On this page

Switch

A toggle switch for boolean settings.

A toggle switch for boolean settings, styled per platform.

Installation

npx touchcn add switch

Usage

<tcn-switch [(checked)]="enabled" />
import { TcnSwitch } from '@/components/ui/switch';

<TcnSwitch checked={enabled} onCheckedChange={setEnabled} />
<script setup lang="ts">
import { TcnSwitch } from '@/components/ui/switch';
</script>

<template>
  <TcnSwitch v-model="enabled" />
</template>

Props

Prop Type Default Description
checked boolean false Toggle state (two-way bound).
onCheckedChange (checked: boolean) => void Change callback (React).
disabled boolean false Disable the switch.

On React, TcnSwitch forwards Radix Switch props.

Source · Angular
export * from './tcn-switch';
import { booleanAttribute, Component, input, model } from '@angular/core';

@Component({
  selector: 'tcn-switch',
  template: `
    <button
      type="button"
      role="switch"
      class="tcn-switch"
      [attr.aria-checked]="checked()"
      [disabled]="disabled()"
      (click)="toggle()"
    >
      <span class="tcn-switch-thumb if-md flex items-center justify-center">
        @if (checked()) {
          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
            <path d="M5 12l4 4L19 7" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" />
          </svg>
        }
      </span>
      <span class="tcn-switch-thumb if-ios"></span>
    </button>
  `,
  host: { class: 'inline-flex' },
})
export class TcnSwitch {
  readonly checked = model(false);
  readonly disabled = input(false, { transform: booleanAttribute });

  protected toggle(): void {
    if (!this.disabled()) {
      this.checked.update((value) => !value);
    }
  }
}
Source · React
export * from './tcn-switch';
import { forwardRef, useState } from 'react';
import type { ComponentPropsWithoutRef } from 'react';
import * as SwitchPrimitive from '@radix-ui/react-switch';
import { cn } from '@touchcn/core';

export interface TcnSwitchProps
  extends Omit<ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>, 'asChild' | 'children'> {}

/**
 * Toggle switch. Radix supplies the accessible `button[role="switch"]` with
 * `aria-checked` and keyboard handling; the two thumbs replicate the platform
 * fork — the MD thumb morphs and shows a checkmark when on, the iOS thumb
 * slides. The active state is mirrored locally so the checkmark can be rendered
 * conditionally (same markup as the Angular component) while still supporting
 * both controlled and uncontrolled usage.
 */
export const TcnSwitch = forwardRef<HTMLButtonElement, TcnSwitchProps>(function TcnSwitch(
  { checked, defaultChecked, onCheckedChange, className, ...props },
  ref,
) {
  const [internalChecked, setInternalChecked] = useState(defaultChecked ?? false);
  const isControlled = checked !== undefined;
  const currentChecked = isControlled ? checked : internalChecked;

  const handleChange = (next: boolean) => {
    if (!isControlled) {
      setInternalChecked(next);
    }
    onCheckedChange?.(next);
  };

  return (
    <SwitchPrimitive.Root
      ref={ref}
      className={cn('tcn-switch', className)}
      checked={currentChecked}
      onCheckedChange={handleChange}
      {...props}
    >
      <span className="tcn-switch-thumb if-md flex items-center justify-center">
        {currentChecked && (
          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
            <path
              d="M5 12l4 4L19 7"
              stroke="currentColor"
              strokeWidth="2.5"
              strokeLinecap="round"
              strokeLinejoin="round"
            />
          </svg>
        )}
      </span>
      <span className="tcn-switch-thumb if-ios" />
    </SwitchPrimitive.Root>
  );
});
Source · Vue
<script setup lang="ts">
import { SwitchRoot } from 'reka-ui';

defineProps<{ disabled?: boolean }>();

/**
 * Two-way bound on/off state. reka-ui `SwitchRoot` supplies the accessible
 * `button[role="switch"]` with `aria-checked` and keyboard handling; the two
 * thumbs replicate the platform fork — the MD thumb morphs and shows a
 * checkmark when on, the iOS thumb slides. The bound value drives the
 * conditional checkmark (same markup as the Angular / React component) while
 * supporting both controlled (`v-model`) and uncontrolled usage.
 */
const model = defineModel<boolean>({ default: false });
</script>

<template>
  <SwitchRoot v-model="model" :disabled="disabled" class="tcn-switch">
    <span class="tcn-switch-thumb if-md flex items-center justify-center">
      <svg v-if="model" width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
        <path
          d="M5 12l4 4L19 7"
          stroke="currentColor"
          stroke-width="2.5"
          stroke-linecap="round"
          stroke-linejoin="round"
        />
      </svg>
    </span>
    <span class="tcn-switch-thumb if-ios" />
  </SwitchRoot>
</template>
export { default as TcnSwitch } from './TcnSwitch.vue';

Last updated on July 24, 2026

Was this page helpful?