Skip to content
120 changes: 77 additions & 43 deletions packages/shared/src/components/fields/Switch.module.css
Original file line number Diff line number Diff line change
@@ -1,61 +1,95 @@
/*
* Toggle / Switch — daily.dev design system.
* Track 44x24 (rounded-8), knob 20x20 (rounded-6) with a 2px inset.
* On press the knob squeezes to the track center (12x12) for tactile feedback,
* then snaps to the destination side on release.
*/

.track {
will-change: background-color, opacity;
transition: background-color 0.1s linear, opacity 0.2s linear;
background: var(--theme-surface-active);
will-change: background-color;
transition: background-color 0.12s linear;
}

.hoverLayer {
opacity: 0;
background: var(--theme-surface-hover);
will-change: opacity, background-color;
transition: opacity 0.15s linear, background-color 0.12s linear;
}

.focusRing {
opacity: 0;
border-color: var(--theme-surface-focus);
transition: opacity 0.1s linear;
}

/* Knob is solid primary white in both themes and states. */
.knob {
will-change: transform, background-color;
transition: background-color 0.1s linear, transform 0.2s linear;
background: theme('colors.raw.salt.0');
transform: translateX(0) scale(1);
transform-origin: center;
will-change: transform, background-color, opacity;
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1),
background-color 0.12s linear, opacity 0.12s linear;
}

.children {
color: var(--theme-text-secondary);
transition: color 0.12s linear;
}

.switch:hover .hoverLayer {
opacity: 1;
}

.switch {
&:hover .knob {
background: var(--theme-text-primary);
}
.switch:hover .children,
.switch:active .children {
color: var(--theme-text-primary);
}

&:hover input:checked ~ * .knob {
background: theme('colors.raw.cabbage.20');
}
/* Keyboard focus */
.switch input:focus-visible ~ * .focusRing,
.switch input:focus-visible ~ * .hoverLayer {
opacity: 1;
}

&:active {
background: none;
}
.switch input:focus-visible ~ .children {
color: var(--theme-text-primary);
}

& input:checked {
& ~ * .track {
background: theme('colors.raw.cabbage.50');
opacity: 0.24;
}
/* Checked (on) — solid brand track, white knob (à la iOS/Chrome) */
.switch input:checked ~ * .track {
background: var(--theme-accent-cabbage-default);
}

& ~ * .knob {
transform: translateX(100%);
background: theme('colors.raw.cabbage.40');
}
.switch input:checked ~ * .hoverLayer {
background: color-mix(in srgb, var(--theme-surface-invert), transparent 88%);
}

& ~ .children {
color: var(--theme-text-primary);
}
}
.switch input:checked ~ * .knob {
transform: translateX(20px) scale(1);
}

:global(.light) .switch {
& input:checked ~ * .knob {
background: theme('colors.raw.cabbage.80');
}
.switch input:checked ~ .children {
color: var(--theme-text-primary);
}

/* Press — knob squeezes toward the track center for both states */
.switch:active .knob,
.switch:active input:checked ~ * .knob {
transform: translateX(10px) scale(0.6);
}

&:hover input:checked ~ * .knob {
background: theme('colors.raw.cabbage.60');
}
/* Disabled — bump the off track so the toggle stays visible; knob unchanged */
.disabled .track {
background: var(--theme-surface-disabled);
}

@media (prefers-color-scheme: light) {
:global(.auto) .switch {
& input:checked ~ * .knob {
background: theme('colors.raw.cabbage.80');
}
.disabled .knob {
opacity: 0.32;
}

&:hover input:checked ~ * .knob {
background: theme('colors.raw.cabbage.60');
}
}
.disabled .children {
color: var(--theme-text-disabled);
}
161 changes: 145 additions & 16 deletions packages/shared/src/components/fields/Switch.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,30 @@
import type {
ForwardedRef,
InputHTMLAttributes,
PointerEvent as ReactPointerEvent,
MouseEvent as ReactMouseEvent,
ReactElement,
ReactNode,
} from 'react';
import React from 'react';
import React, { useCallback, useRef, useState } from 'react';
import classNames from 'classnames';
import styles from './Switch.module.css';

// Knob slides this many px between the off and on positions (matches the CSS).
const KNOB_TRAVEL = 20;
// Knob center when translateX is 0 (2px track inset + 10px half knob width).
const KNOB_CENTER_OFFSET = 12;
// How far the pointer must move before a hold turns into a drag.
const DRAG_THRESHOLD = 3;

interface DragState {
pointerId: number;
startX: number;
startChecked: boolean;
moved: boolean;
lastPos: number;
}

export interface SwitchProps extends InputHTMLAttributes<HTMLInputElement> {
children?: ReactNode;
className?: string;
Expand Down Expand Up @@ -37,21 +54,123 @@ function SwitchComponent(
}: SwitchProps,
ref: ForwardedRef<HTMLLabelElement>,
): ReactElement {
const inputRef = useRef<HTMLInputElement>(null);
const trackRef = useRef<HTMLSpanElement>(null);
const dragRef = useRef<DragState | null>(null);
// A drag ends with a synthetic click we must swallow so it doesn't re-toggle.
const suppressClickRef = useRef(false);
const [dragX, setDragX] = useState<number | null>(null);

const handlePointerDown = useCallback(
(event: ReactPointerEvent<HTMLSpanElement>) => {
if (disabled || event.button !== 0) {
return;
}
dragRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startChecked: !!checked,
moved: false,
lastPos: checked ? KNOB_TRAVEL : 0,
};
trackRef.current?.setPointerCapture(event.pointerId);
},
[disabled, checked],
);

const handlePointerMove = useCallback(
(event: ReactPointerEvent<HTMLSpanElement>) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) {
return;
}
if (
!drag.moved &&
Math.abs(event.clientX - drag.startX) < DRAG_THRESHOLD
) {
return;
}
const rect = trackRef.current?.getBoundingClientRect();
if (!rect) {
return;
}
drag.moved = true;
const pos = Math.min(
KNOB_TRAVEL,
Math.max(0, event.clientX - rect.left - KNOB_CENTER_OFFSET),
);
drag.lastPos = pos;
setDragX(pos);
},
[],
);

const endDrag = useCallback(
(event: ReactPointerEvent<HTMLSpanElement>) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) {
return;
}
dragRef.current = null;
if (trackRef.current?.hasPointerCapture(event.pointerId)) {
trackRef.current.releasePointerCapture(event.pointerId);
}
if (drag.moved) {
const target = drag.lastPos >= KNOB_TRAVEL / 2;
// Swallow the click that fires after a drag so we toggle only once.
suppressClickRef.current = true;
if (target !== !!checked) {
if (inputRef.current) {
inputRef.current.checked = target;
}
onToggle?.();
}
}
// Drop the inline transform so the knob snaps to its side with the CSS transition.
setDragX(null);
},
[checked, onToggle],
);

const handlePointerCancel = useCallback(() => {
if (!dragRef.current) {
return;
}
dragRef.current = null;
setDragX(null);
}, []);

const handleClick = useCallback(
(event: ReactMouseEvent<HTMLLabelElement>) => {
if (suppressClickRef.current) {
event.preventDefault();
suppressClickRef.current = false;
}
},
[],
);

const knobStyle =
dragX !== null
? { transform: `translateX(${dragX}px) scale(0.6)`, transition: 'none' }
: undefined;

return (
// eslint-disable-next-line jsx-a11y/label-has-associated-control
// eslint-disable-next-line jsx-a11y/label-has-associated-control, jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions
<label
className={classNames(
className,
'group relative flex items-center',
disabled
? 'pointer-events-none cursor-not-allowed opacity-32'
: 'cursor-pointer',
disabled ? 'cursor-not-allowed' : 'cursor-pointer',
styles.switch,
disabled && styles.disabled,
)}
ref={ref}
onClick={handleClick}
>
<input
{...props}
ref={inputRef}
disabled={disabled}
id={inputId}
name={name}
Expand All @@ -61,31 +180,41 @@ function SwitchComponent(
className="absolute h-0 w-0 opacity-0"
/>
<span
className={classNames(
'relative block',
compact ? 'h-4 w-8' : 'h-5 w-10',
)}
ref={trackRef}
className="relative block h-6 w-11 touch-none select-none"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={endDrag}
onPointerCancel={handlePointerCancel}
>
<span
className={classNames('absolute inset-0 rounded-8', styles.track)}
/>
<span
className={classNames(
'absolute inset-0.5 rounded-6',
styles.hoverLayer,
)}
/>
<span
className={classNames(
'absolute bottom-0 left-0 top-0 my-auto w-full bg-theme-overlay-quaternary',
compact ? 'h-2.5 rounded-3' : 'h-3 rounded-4',
styles.track,
'pointer-events-none absolute inset-0 rounded-8 border-2 border-solid',
styles.focusRing,
)}
/>
<span
style={knobStyle}
className={classNames(
'absolute left-0 top-0 bg-text-tertiary',
compact ? 'h-4 w-4 rounded-3' : 'h-5 w-5 rounded-6',
'absolute left-0.5 top-0.5 h-5 w-5 rounded-6',
styles.knob,
)}
/>
</span>
{children ? (
<span
className={classNames(
'ml-3 font-bold text-text-tertiary',
defaultTypo && 'typo-footnote',
'ml-3 font-medium antialiased',
defaultTypo && (compact ? 'typo-footnote' : 'typo-callout'),
styles.children,
labelClassName,
)}
Expand Down
Loading
Loading