From 42cd4a85a082908244cf56fa678886d641744e86 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sun, 31 May 2026 13:35:21 +0300 Subject: [PATCH 1/4] feat(shared): redesign Toggle/Switch per Figma design system Update the shared Switch to the new toggle design: a 44x24 rounded-8 track with a rounded-square knob, surface-float/cabbage tinting, a focus ring, and a press interaction that squeezes the knob toward the track centre. Adds a Storybook page comparing the previous and new designs across every state, animation, and interaction. Co-authored-by: Cursor --- .../src/components/fields/Switch.module.css | 131 ++++--- .../shared/src/components/fields/Switch.tsx | 33 +- .../components/fields/Switch.stories.tsx | 319 ++++++++++++++++++ .../fields/legacy/LegacySwitch.module.css | 61 ++++ .../components/fields/legacy/LegacySwitch.tsx | 105 ++++++ 5 files changed, 590 insertions(+), 59 deletions(-) create mode 100644 packages/storybook/stories/components/fields/Switch.stories.tsx create mode 100644 packages/storybook/stories/components/fields/legacy/LegacySwitch.module.css create mode 100644 packages/storybook/stories/components/fields/legacy/LegacySwitch.tsx diff --git a/packages/shared/src/components/fields/Switch.module.css b/packages/shared/src/components/fields/Switch.module.css index c23c2f78dd8..cf2d4f46c97 100644 --- a/packages/shared/src/components/fields/Switch.module.css +++ b/packages/shared/src/components/fields/Switch.module.css @@ -1,61 +1,106 @@ +/* + * 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-float); + 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 { - will-change: transform, background-color; - transition: background-color 0.1s linear, transform 0.2s linear; + background: var(--theme-surface-secondary); + 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-tertiary); + transition: color 0.12s linear; +} + +/* Off knob turns to the primary surface on hover / focus / press */ +.switch:hover input:not(:checked) ~ * .knob, +.switch:active input:not(:checked) ~ * .knob, +.switch input:not(:checked):focus-visible ~ * .knob { + background: var(--theme-surface-primary); } -.switch { - &:hover .knob { - background: var(--theme-text-primary); - } +.switch:hover .hoverLayer { + opacity: 1; +} - &:hover input:checked ~ * .knob { - background: theme('colors.raw.cabbage.20'); - } +.switch:hover .children, +.switch:active .children { + color: var(--theme-text-primary); +} - &:active { - background: none; - } +/* Keyboard focus */ +.switch input:focus-visible ~ * .focusRing, +.switch input:focus-visible ~ * .hoverLayer { + opacity: 1; +} - & input:checked { - & ~ * .track { - background: theme('colors.raw.cabbage.50'); - opacity: 0.24; - } +.switch input:focus-visible ~ .children { + color: var(--theme-text-primary); +} - & ~ * .knob { - transform: translateX(100%); - background: theme('colors.raw.cabbage.40'); - } +/* Checked (on) */ +.switch input:checked ~ * .track { + background: color-mix( + in srgb, + var(--theme-accent-cabbage-bolder), + transparent 84% + ); +} - & ~ .children { - color: var(--theme-text-primary); - } - } +.switch input:checked ~ * .hoverLayer { + background: color-mix( + in srgb, + var(--theme-accent-cabbage-bolder), + transparent 88% + ); } -:global(.light) .switch { - & input:checked ~ * .knob { - background: theme('colors.raw.cabbage.80'); - } +.switch input:checked ~ * .knob { + background: var(--theme-accent-cabbage-default); + transform: translateX(20px) scale(1); +} + +.switch input:checked ~ .children { + color: var(--theme-text-primary); +} - &:hover input:checked ~ * .knob { - background: theme('colors.raw.cabbage.60'); - } +/* Press — knob squeezes toward the track center for both states */ +.switch:active .knob, +.switch:active input:checked ~ * .knob { + transform: translateX(10px) scale(0.6); } -@media (prefers-color-scheme: light) { - :global(.auto) .switch { - & input:checked ~ * .knob { - background: theme('colors.raw.cabbage.80'); - } +/* Disabled */ +.disabled .knob { + opacity: 0.32; +} - &:hover input:checked ~ * .knob { - background: theme('colors.raw.cabbage.60'); - } - } +.disabled .children { + color: var(--theme-text-disabled); } diff --git a/packages/shared/src/components/fields/Switch.tsx b/packages/shared/src/components/fields/Switch.tsx index 5f417fb9594..273732f19d7 100644 --- a/packages/shared/src/components/fields/Switch.tsx +++ b/packages/shared/src/components/fields/Switch.tsx @@ -43,10 +43,9 @@ function SwitchComponent( 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} > @@ -60,23 +59,25 @@ function SwitchComponent( onChange={onToggle} className="absolute h-0 w-0 opacity-0" /> - + + + @@ -84,8 +85,8 @@ function SwitchComponent( {children ? ( = { + title: 'Components/Fields/Switch', + component: Switch, + parameters: { + layout: 'fullscreen', + design: { + type: 'figma', + url: figmaUrl, + }, + }, + tags: ['autodocs'], + argTypes: { + checked: { control: 'boolean' }, + disabled: { control: 'boolean' }, + compact: { + control: 'boolean', + description: + 'Small (footnote) label when true, medium (callout) when false', + }, + children: { control: 'text' }, + }, +}; + +export default meta; +type Story = StoryObj; + +/** + * A self-contained, interactive toggle that owns its checked state so the real + * hover / focus / press / toggle behaviour can be reviewed by clicking around. + */ +const InteractiveToggle = ({ + variant, + ...props +}: Omit & { + variant: 'legacy' | 'new'; +}) => { + const id = useId(); + const [checked, setChecked] = useState(Boolean(props.checked)); + const Component = variant === 'legacy' ? LegacySwitch : Switch; + + return ( + setChecked((prev) => !prev)} + /> + ); +}; + +// --- Layout primitives for the comparison page ---------------------------- + +const Page = ({ children }: { children: React.ReactNode }) => ( +
+ {children} +
+); + +const PageHeader = () => ( +
+ + Design system · Review + +

+ Toggle / Switch — before & after +

+

+ A side-by-side review of the previous toggle and the redesigned toggle + from Figma. Every toggle on this page is live — hover, press, and tab to + focus to verify the states, animations, and interactions. Use the theme + toolbar to check light and dark. +

+
+); + +const Section = ({ + title, + description, + children, +}: { + title: string; + description?: string; + children: React.ReactNode; +}) => ( +
+
+

{title}

+ {description && ( +

+ {description} +

+ )} +
+ {children} +
+); + +const Card = ({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) => ( +
+ + {label} + +
{children}
+
+); + +const comparisonGrid = { gridTemplateColumns: '200px 1fr 1fr' } as const; + +/** A "previous" vs "new" pair for the same set of props. */ +const ComparisonRow = ({ + title, + caption, + switchProps, +}: { + title: string; + caption?: string; + switchProps: Omit; +}) => ( +
+
+ {title} + {caption && ( + {caption} + )} +
+
+ +
+
+ +
+
+); + +const ComparisonHeader = () => ( +
+ + + Previous + + + New design + +
+); + +// --- Stories --------------------------------------------------------------- + +/** + * The headline comparison: previous design on the left, redesign on the right, + * across every static state. Hover / focus / press are live on each toggle. + */ +export const Comparison: Story = { + render: () => ( + + + +
+
+ + + + + + + + +
+
+ +
+
+ + + + + + + + + + + + +
+
+
+ ), +}; + +/** Just the new toggle, in its on and off states with and without a label. */ +export const NewDesign: Story = { + render: () => ( + +
+
+ + On by default + + + Off by default + + + + + Disabled on + + + Disabled off + +
+
+
+ ), +}; + +/** Fully controllable playground driven by Storybook controls. */ +export const Playground: Story = { + args: { + checked: true, + disabled: false, + compact: true, + children: 'Toggle label', + }, + render: (args) => ( + +
+ + {args.children} + +
+
+ ), +}; diff --git a/packages/storybook/stories/components/fields/legacy/LegacySwitch.module.css b/packages/storybook/stories/components/fields/legacy/LegacySwitch.module.css new file mode 100644 index 00000000000..bae157e3ce3 --- /dev/null +++ b/packages/storybook/stories/components/fields/legacy/LegacySwitch.module.css @@ -0,0 +1,61 @@ +.track { + will-change: background-color, opacity; + transition: background-color 0.1s linear, opacity 0.2s linear; +} + +.knob { + will-change: transform, background-color; + transition: background-color 0.1s linear, transform 0.2s linear; +} + +.switch { + &:hover .knob { + background: var(--theme-text-primary); + } + + &:hover input:checked ~ * .knob { + background: theme('colors.raw.cabbage.20'); + } + + &:active { + background: none; + } + + & input:checked { + & ~ * .track { + background: theme('colors.raw.cabbage.50'); + opacity: 0.24; + } + + & ~ * .knob { + transform: translateX(100%); + background: theme('colors.raw.cabbage.40'); + } + + & ~ .children { + color: var(--theme-text-primary); + } + } +} + +:global(.light) .switch { + & input:checked ~ * .knob { + background: theme('colors.raw.cabbage.80'); + } + + &:hover input:checked ~ * .knob { + background: theme('colors.raw.cabbage.60'); + } +} + +@media (prefers-color-scheme: light) { + :global(.auto) .switch { + & input:checked ~ * .knob { + background: theme('colors.raw.cabbage.80'); + } + + &:hover input:checked ~ * .knob { + background: theme('colors.raw.cabbage.60'); + } + } +} diff --git a/packages/storybook/stories/components/fields/legacy/LegacySwitch.tsx b/packages/storybook/stories/components/fields/legacy/LegacySwitch.tsx new file mode 100644 index 00000000000..7051a3ce993 --- /dev/null +++ b/packages/storybook/stories/components/fields/legacy/LegacySwitch.tsx @@ -0,0 +1,105 @@ +import type { + ForwardedRef, + InputHTMLAttributes, + ReactElement, + ReactNode, +} from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import styles from './LegacySwitch.module.css'; + +// Snapshot of the previous Switch design, kept only for the Storybook +// before/after comparison page. Do not use in production code. +export interface LegacySwitchProps + extends InputHTMLAttributes { + children?: ReactNode; + className?: string; + labelClassName?: string; + inputId: string; + name: string; + checked?: boolean; + onToggle?: () => unknown; + compact?: boolean; + defaultTypo?: boolean; +} + +function LegacySwitchComponent( + { + className, + labelClassName, + inputId, + name, + checked, + children, + onToggle, + compact = true, + defaultTypo = true, + disabled, + 'aria-label': ariaLabel, + ...props + }: LegacySwitchProps, + ref: ForwardedRef, +): ReactElement { + return ( + // eslint-disable-next-line jsx-a11y/label-has-associated-control + + ); +} + +export const LegacySwitch = React.forwardRef(LegacySwitchComponent); From ee82182c3b9dd02ac5928e444e816f5d95fb43be Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Mon, 1 Jun 2026 09:32:09 +0300 Subject: [PATCH 2/4] style(switch): refine toggle label weight, smoothing, and color Use font-medium + antialiased and bump label color from text-tertiary to text-secondary for better readability per Figma. Co-authored-by: Cursor --- packages/shared/src/components/fields/Switch.module.css | 2 +- packages/shared/src/components/fields/Switch.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/components/fields/Switch.module.css b/packages/shared/src/components/fields/Switch.module.css index cf2d4f46c97..1d1ae2c8101 100644 --- a/packages/shared/src/components/fields/Switch.module.css +++ b/packages/shared/src/components/fields/Switch.module.css @@ -34,7 +34,7 @@ } .children { - color: var(--theme-text-tertiary); + color: var(--theme-text-secondary); transition: color 0.12s linear; } diff --git a/packages/shared/src/components/fields/Switch.tsx b/packages/shared/src/components/fields/Switch.tsx index 273732f19d7..588003e0b73 100644 --- a/packages/shared/src/components/fields/Switch.tsx +++ b/packages/shared/src/components/fields/Switch.tsx @@ -85,7 +85,7 @@ function SwitchComponent( {children ? ( Date: Mon, 1 Jun 2026 12:31:03 +0300 Subject: [PATCH 3/4] feat(switch): brand-filled track, white knob, and draggable knob - Color: checked track fills with the solid brand color and the knob is a solid white in both themes/states; off track uses the more visible surface-active, disabled uses surface-disabled. - Keep the press squeeze and snap transitions. - Cursor: click-and-hold then drag the knob left/right (iOS/macOS style); it follows the pointer at the squeezed scale and snaps to the nearest side on release. Tap and keyboard toggling are preserved; touch drag doesn't scroll. Co-authored-by: Cursor --- .../src/components/fields/Switch.module.css | 33 ++--- .../shared/src/components/fields/Switch.tsx | 134 +++++++++++++++++- .../components/fields/Switch.stories.tsx | 2 +- 3 files changed, 143 insertions(+), 26 deletions(-) diff --git a/packages/shared/src/components/fields/Switch.module.css b/packages/shared/src/components/fields/Switch.module.css index 1d1ae2c8101..256be269eed 100644 --- a/packages/shared/src/components/fields/Switch.module.css +++ b/packages/shared/src/components/fields/Switch.module.css @@ -6,7 +6,7 @@ */ .track { - background: var(--theme-surface-float); + background: var(--theme-surface-active); will-change: background-color; transition: background-color 0.12s linear; } @@ -24,8 +24,9 @@ transition: opacity 0.1s linear; } +/* Knob is solid primary white in both themes and states. */ .knob { - background: var(--theme-surface-secondary); + background: theme('colors.raw.salt.0'); transform: translateX(0) scale(1); transform-origin: center; will-change: transform, background-color, opacity; @@ -38,13 +39,6 @@ transition: color 0.12s linear; } -/* Off knob turns to the primary surface on hover / focus / press */ -.switch:hover input:not(:checked) ~ * .knob, -.switch:active input:not(:checked) ~ * .knob, -.switch input:not(:checked):focus-visible ~ * .knob { - background: var(--theme-surface-primary); -} - .switch:hover .hoverLayer { opacity: 1; } @@ -64,25 +58,16 @@ color: var(--theme-text-primary); } -/* Checked (on) */ +/* Checked (on) — solid brand track, white knob (à la iOS/Chrome) */ .switch input:checked ~ * .track { - background: color-mix( - in srgb, - var(--theme-accent-cabbage-bolder), - transparent 84% - ); + background: var(--theme-accent-cabbage-default); } .switch input:checked ~ * .hoverLayer { - background: color-mix( - in srgb, - var(--theme-accent-cabbage-bolder), - transparent 88% - ); + background: color-mix(in srgb, var(--theme-surface-invert), transparent 88%); } .switch input:checked ~ * .knob { - background: var(--theme-accent-cabbage-default); transform: translateX(20px) scale(1); } @@ -96,7 +81,11 @@ transform: translateX(10px) scale(0.6); } -/* Disabled */ +/* Disabled — bump the off track so the toggle stays visible; knob unchanged */ +.disabled .track { + background: var(--theme-surface-disabled); +} + .disabled .knob { opacity: 0.32; } diff --git a/packages/shared/src/components/fields/Switch.tsx b/packages/shared/src/components/fields/Switch.tsx index 588003e0b73..ee77f2fc0b4 100644 --- a/packages/shared/src/components/fields/Switch.tsx +++ b/packages/shared/src/components/fields/Switch.tsx @@ -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 { children?: ReactNode; className?: string; @@ -37,8 +54,109 @@ function SwitchComponent( }: SwitchProps, ref: ForwardedRef, ): ReactElement { + const inputRef = useRef(null); + const trackRef = useRef(null); + const dragRef = useRef(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(null); + + const handlePointerDown = useCallback( + (event: ReactPointerEvent) => { + 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) => { + 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) => { + 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) => { + 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