Switch
An instant on/off setting. Flipping it applies the change; there is no Save button.
Playground
Change a prop and watch it. The code below follows along.
Live
Open full cardInstall and import
Switch is in @dovetail-ds/react. Install it with React, if you haven't:
npm install @dovetail-ds/react react react-dom
import { Switch } from "@dovetail-ds/react";
Import @dovetail-ds/react/styles.css once at your app's root, then your theme after it. Setup covers the stylesheets, fonts and dark mode.
Guidelines
An instant on/off setting. Flipping it applies the change; there is no Save button.
Use it when
- A preference takes effect immediately: notifications, dark mode, autopay.
Don't use it when
- The value is submitted with a form. Use Checkbox; a switch that needs saving lies about when it took effect.
- The choice is not two opposite states.
Example
<Switch label="Autopay" hint="Charge the card on file each month" labelPosition="start" defaultChecked />
Variants
labelPosition="start" is the settings-row pattern: label left, switch pushed to the right edge.
Accessibility
Renders role="switch" on a real checkbox input, so state is announced as on/off rather than checked/unchecked.
Content
Label names the thing being switched, not the action. "Autopay", not "Turn on autopay".
Props
The typed contract, from Switch.d.ts.
import * as React from "react";
/** Instant on/off setting. The change applies the moment it is flipped. */
export interface SwitchProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "onChange" | "type"> {
label?: React.ReactNode;
hint?: string;
checked?: boolean;
defaultChecked?: boolean;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
disabled?: boolean;
/** "start" puts the label on the left and pushes the switch right — the settings-row pattern. @default "end" */
labelPosition?: "start" | "end";
}
export declare function Switch(props: SwitchProps): React.JSX.Element;
Tokens it reads
Every custom property Switch.jsx resolves, read from the source: 13 semantic, 1 primitive. Component tier first, because that is the one to reach for.
- semantic Shared roles. Override one and everything using that role moves with it.
- primitive Raw values. A component reading one directly is a deliberate exception.
| Token | Tier | Declared as |
|---|---|---|
--dt-border-strong | semantic | var(--dt-color-neutral-400) |
--dt-elevation-1 | semantic | var(--dt-shadow-raw-1) |
--dt-motion-micro | semantic | var(--dt-duration-100) var(--dt-easing-standard) |
--dt-radius-pill | semantic | var(--dt-radius-raw-full) |
--dt-space-inline-sm | semantic | var(--dt-dim-3) |
--dt-surface-action | semantic | var(--dt-color-neutral-900) |
--dt-text-body-sm-family | semantic | var(--dt-font-family-sans) |
--dt-text-body-sm-line | semantic | var(--dt-line-height-sm) |
--dt-text-body-sm-size | semantic | var(--dt-font-size-sm) |
--dt-text-body-xs-line | semantic | var(--dt-line-height-xs) |
--dt-text-body-xs-size | semantic | var(--dt-font-size-xs) |
--dt-text-primary | semantic | var(--dt-color-neutral-900) |
--dt-text-secondary | semantic | var(--dt-color-neutral-600) |
--dt-color-white | primitive | oklch(1 0 0) |
Source
React only, no dependencies, every value a token.
import React from "react";
export function Switch({ label, hint, checked, defaultChecked = false, onChange, disabled = false, id, labelPosition = "end", style, ...rest }) {
const [internal, setInternal] = React.useState(defaultChecked);
const auto = React.useId();
const switchId = id || auto;
const isOn = checked !== undefined ? checked : internal;
const handle = (e) => {
if (checked === undefined) setInternal(e.target.checked);
onChange && onChange(e);
};
const track = (
<span
aria-hidden="true"
style={{
flex: "none", position: "relative", width: 40, height: 24, borderRadius: "var(--dt-radius-pill)",
background: isOn ? "var(--dt-surface-action)" : "var(--dt-border-strong)",
transition: "background var(--dt-motion-micro)",
}}
>
<span
style={{
position: "absolute", top: 3, left: isOn ? 19 : 3, width: 18, height: 18, borderRadius: "50%",
background: "var(--dt-color-white)", boxShadow: "var(--dt-elevation-1)",
transition: "left var(--dt-motion-micro)",
}}
/>
</span>
);
const text = (
<span style={{ display: "flex", flexDirection: "column", gap: 2 }}>
<span style={{ fontFamily: "var(--dt-text-body-sm-family)", fontSize: "var(--dt-text-body-sm-size)", lineHeight: "var(--dt-text-body-sm-line)", color: "var(--dt-text-primary)" }}>{label}</span>
{hint && <span style={{ fontSize: "var(--dt-text-body-xs-size)", lineHeight: "var(--dt-text-body-xs-line)", color: "var(--dt-text-secondary)" }}>{hint}</span>}
</span>
);
return (
<div style={{ display: "flex", opacity: disabled ? 0.6 : 1, ...style }}>
<input
type="checkbox" role="switch" id={switchId} checked={isOn} onChange={handle} disabled={disabled}
style={{ position: "absolute", opacity: 0, width: 0, height: 0 }}
{...rest}
/>
<label
htmlFor={switchId}
style={{
display: "flex", alignItems: hint ? "flex-start" : "center", gap: "var(--dt-space-inline-sm)",
cursor: disabled ? "not-allowed" : "pointer",
justifyContent: labelPosition === "start" ? "space-between" : undefined,
width: labelPosition === "start" ? "100%" : undefined,
flexDirection: labelPosition === "start" ? "row-reverse" : "row",
}}
>
{track}
{text}
</label>
</div>
);
}
In context
Switch also appears on the forms reference cards, alongside the rest of its family.