Toggle
Binary on/off switch.
Installation
$
npx @309-thingspire/ui@latest add toggleUsage
import { Toggle } from "@/components/toggle/toggle"<Toggle />Examples
Live preview rendered from Toggle.preview.tsx. Switch to the Code tab to view the underlying component source.
Loading preview…
import React, { useEffect, useMemo, useState } from 'react';
import { border, colors, radius, shadows, spacing } from '../../style-tokens';
import type { ToggleProps, ToggleSize, ToggleVisualState } from './Toggle.types';
type SizeConfig = {
trackWidth: number;
trackHeight: number;
knobSize: number;
insetPadding: number;
activeInset: number;
};
const SIZE_CONFIG: Record<ToggleSize, SizeConfig> = {
sm: {
trackWidth: spacing.scale['28'],
trackHeight: spacing.scale['16'],
knobSize: spacing.scale['12'],
insetPadding: spacing.scale['2'],
activeInset: spacing.scale['14'],
},
md: {
trackWidth: spacing.scale['20'] + spacing.scale['14'],
trackHeight: spacing.scale['20'],
knobSize: spacing.scale['16'],
insetPadding: spacing.scale['2'],
activeInset: spacing.scale['16'],
},
};
const toggleBackground = colors.semantic.theme.background.toggle;
function resolveVisualState(
forcedState: ToggleVisualState | undefined,
disabled: boolean,
hovered: boolean,
focused: boolean,
): ToggleVisualState {
if (disabled || forcedState === 'disabled') {
return 'disabled';
}
if (forcedState && forcedState !== 'default') {
return forcedState;
}
if (focused) {
return 'focus';
}
if (hovered) {
return 'hover';
}
return 'default';
}
function resolveTrackColor(checked: boolean, visualState: ToggleVisualState): string {
if (visualState === 'disabled') {
return checked ? toggleBackground.activeDisabled : toggleBackground.disabled;
}
if (visualState === 'hover') {
return checked ? toggleBackground.activeHover : toggleBackground.hover;
}
return checked ? toggleBackground.active : toggleBackground.default;
}
function resolveKnobColor(checked: boolean, visualState: ToggleVisualState): string {
if (visualState === 'disabled' && !checked) {
return toggleBackground.handleDisabled;
}
return toggleBackground.handle;
}
function resolveTrackShadow(visualState: ToggleVisualState): string {
if (visualState !== 'focus') {
return 'none';
}
return shadows.focusRing.light.css;
}
function resolveKnobShadow(visualState: ToggleVisualState): string {
if (visualState === 'disabled') {
return 'none';
}
return shadows.elevation.xs.css;
}
export function Toggle({
size = 'md',
checked,
defaultChecked = false,
disabled = false,
state,
onCheckedChange,
onClick,
onFocus,
onBlur,
onMouseEnter,
onMouseLeave,
style,
...rest
}: ToggleProps) {
const isControlled = checked !== undefined;
const [internalChecked, setInternalChecked] = useState(defaultChecked);
const [hovered, setHovered] = useState(false);
const [focused, setFocused] = useState(false);
useEffect(() => {
if (isControlled) {
return;
}
setInternalChecked(defaultChecked);
}, [defaultChecked, isControlled]);
const resolvedChecked = isControlled ? Boolean(checked) : internalChecked;
const visualState = resolveVisualState(state, disabled, hovered, focused);
const isDisabled = visualState === 'disabled';
const config = SIZE_CONFIG[size];
const knobPadding = useMemo(() => {
return resolvedChecked
? {
paddingLeft: config.activeInset,
paddingRight: config.insetPadding,
}
: {
paddingLeft: config.insetPadding,
paddingRight: config.activeInset,
};
}, [config.activeInset, config.insetPadding, resolvedChecked]);
const handleToggle = (event: React.MouseEvent<HTMLButtonElement>) => {
if (isDisabled) {
event.preventDefault();
return;
}
const nextChecked = !resolvedChecked;
if (!isControlled) {
setInternalChecked(nextChecked);
}
onCheckedChange?.(nextChecked);
onClick?.(event);
};
return (
<button
{...rest}
type="button"
role="switch"
aria-checked={resolvedChecked}
aria-disabled={isDisabled || undefined}
disabled={isDisabled}
onClick={handleToggle}
onFocus={(event) => {
setFocused(true);
onFocus?.(event);
}}
onBlur={(event) => {
setFocused(false);
onBlur?.(event);
}}
onMouseEnter={(event) => {
setHovered(true);
onMouseEnter?.(event);
}}
onMouseLeave={(event) => {
setHovered(false);
onMouseLeave?.(event);
}}
style={{
width: config.trackWidth,
height: config.trackHeight,
display: 'inline-flex',
alignItems: 'center',
justifyContent: resolvedChecked ? 'flex-end' : 'flex-start',
gap: spacing.scale['0'],
paddingTop: config.insetPadding,
paddingBottom: config.insetPadding,
borderStyle: 'solid',
borderWidth: border.width['0'],
borderRadius: radius.scale.full,
backgroundColor: resolveTrackColor(resolvedChecked, visualState),
boxShadow: resolveTrackShadow(visualState),
cursor: isDisabled ? 'not-allowed' : 'pointer',
boxSizing: 'border-box',
...knobPadding,
...style,
}}
>
<span
aria-hidden="true"
style={{
width: config.knobSize,
height: config.knobSize,
borderRadius: radius.scale.full,
backgroundColor: resolveKnobColor(resolvedChecked, visualState),
boxShadow: resolveKnobShadow(visualState),
display: 'inline-block',
flexShrink: 0,
}}
/>
</button>
);
}
export default Toggle;
API Reference
Props 문서 준비 중입니다. (component-spec.json 추가 시 표시됩니다.)