fix: centre the toggle knob inside its track (#11314)

* fix: centre the toggle knob inside its track

The knob was positioned against the toggle's wrapper rather than the track,
so its 2px inset resolved to a 1px gap inside the track's 1px border. On a 1x
screen a toggle that lands on a fractional x blurs its edges across a whole
pixel, which swallows that 1px gap and makes the knob look like it overflows
the track.

Position the knob inside the track's border with a 2px gap on every side,
size the toggle in whole px so the 18px root font of large screens cannot
make the track fractional, and give each size an explicit checked translate
now that the knob is no longer exactly one translate-x-full wide.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: hoist the toggle knob's shared inset out of the size branches

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(frontend): add a Toggles tab to the kitchen sink

Covers the four sizes, the three colors, the label and EE-badge variants, and
a grid of quarter-pixel offsets: the knob's gap against the track border only
misreads once the track lands between device pixels.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem
2026-09-24 15:12:09 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 1b74939952
commit d02ff9ac24
2 changed files with 94 additions and 5 deletions
+11 -5
View File
@@ -93,6 +93,8 @@
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- Keeps the sr-only checkbox's absolute box on the toggle; it does not size the knob,
which resolves against the track. -->
<div
class="relative"
onclick={stopPropagation(bubble('click'))}
@@ -116,21 +118,25 @@
dispatch('change', !!checked)
})}
/>
<!-- Knob offsets resolve against the track's padding box, so the track stays
relative. Whole px and 2px of inset: at 1x a track on a fractional x blurs its
edge over a whole pixel, which swallows a thinner gap.
knob = h - 6, checked translate = w - h. -->
<div
class={classNames(
"transition-all bg-surface-sunken rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:bg-surface after:border-white after:border after:rounded-full after:transition-all items-center",
"relative transition-all bg-surface-sunken rounded-full peer peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-surface after:border-white after:border after:rounded-full after:transition-all items-center",
color == 'red'
? 'peer-checked:bg-red-600'
: color == 'blue'
? 'peer-checked:bg-blue-400 '
: 'peer-checked:bg-nord-950 dark:peer-checked:bg-nord-900',
size === 'md'
? 'w-11 h-6 after:top-0.5 after:left-[2px] after:h-5 after:w-5'
? 'w-[44px] h-[24px] after:h-[18px] after:w-[18px] peer-checked:after:translate-x-[20px]'
: size === 'sm'
? 'w-9 h-5 after:top-0.5 after:left-[2px] after:h-4 after:w-4'
? 'w-[36px] h-[20px] after:h-[14px] after:w-[14px] peer-checked:after:translate-x-[16px]'
: size === '2xs'
? 'w-5 h-3 after:top-0.5 after:left-[2px] after:h-2 after:w-2'
: 'w-7 h-4 after:top-0.5 after:left-[2px] after:h-3 after:w-3',
? 'w-[20px] h-[12px] after:h-[6px] after:w-[6px] peer-checked:after:translate-x-[8px]'
: 'w-[28px] h-[16px] after:h-[10px] after:w-[10px] peer-checked:after:translate-x-[12px]',
inputBorderClass()
)}
></div>
@@ -4,6 +4,7 @@
import TabContent from '$lib/components/common/tabs/TabContent.svelte'
import Tabs from '$lib/components/common/tabs/Tabs.svelte'
import DarkModeToggle from '$lib/components/sidebar/DarkModeToggle.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import GfmMarkdown from '$lib/components/GfmMarkdown.svelte'
import AssistantMessage from '$lib/components/copilot/chat/AssistantMessage.svelte'
import type { DisplayMessage } from '$lib/components/copilot/chat/shared'
@@ -133,12 +134,23 @@ That's the full round-trip.`
role: 'assistant',
content: chatSampleContent
}
const toggleSizes = ['2xs', 'xs', 'sm', 'md'] as const
const toggleColors = ['blue', 'red', 'nord'] as const
// The knob's gap against the track border is thin enough that it only misreads once the
// track lands between device pixels, which needs a 1x screen. These offsets put one row of
// each size on every quarter-pixel so the worst case is on the page rather than left to
// whichever label widths a real view happens to have.
const subpixelOffsets = [0, 0.25, 0.5, 0.75]
let toggleChecked = $state(true)
let toggleDisabled = $state(false)
</script>
<DarkModeToggle forcedDarkMode={false} />
<Tabs bind:selected={tab}>
<Tab value="button" label="Buttons" />
<Tab value="toggle" label="Toggles" />
<Tab value="markdown" label="Markdown" />
<Tab value="chat" label="AI Chat" />
<Tab value="scrollbar" label="Scrollbar" />
@@ -195,6 +207,77 @@ That's the full round-trip.`
<Button variant="default" {dropdownItems}>Lorem</Button>
</div>
</TabContent>
<TabContent value="toggle" class="p-4 flex gap-6 flex-col">
<div class="flex flex-row items-center gap-4">
<Toggle bind:checked={toggleChecked} options={{ right: 'Checked' }} size="xs" />
<Toggle bind:checked={toggleDisabled} options={{ right: 'Disabled' }} size="xs" />
</div>
<div>
<div class="font-bold text-md mb-2">Sizes</div>
<div class="flex flex-row items-center gap-6">
{#each toggleSizes as size}
<Toggle
{size}
checked={toggleChecked}
disabled={toggleDisabled}
options={{ right: size }}
/>
{/each}
</div>
</div>
<div>
<div class="font-bold text-md mb-2">Colors</div>
<div class="flex flex-row items-center gap-6">
{#each toggleColors as color}
<Toggle
{color}
checked={toggleChecked}
disabled={toggleDisabled}
options={{ right: color }}
/>
{/each}
</div>
</div>
<div>
<div class="font-bold text-md mb-2">Labels</div>
<div class="flex flex-row items-center gap-6">
<Toggle checked={toggleChecked} disabled={toggleDisabled} />
<Toggle
checked={toggleChecked}
disabled={toggleDisabled}
options={{ left: 'Left', right: 'Right' }}
/>
<Toggle
checked={toggleChecked}
disabled={toggleDisabled}
options={{ right: 'With a tooltip', rightTooltip: 'Tooltip body' }}
/>
<Toggle checked={toggleChecked} disabled eeOnly options={{ right: 'EE only' }} />
</div>
</div>
<div>
<div class="font-bold text-md mb-2">Quarter-pixel offsets</div>
<div class="text-xs text-tertiary mb-2">
Zoom a 1x screen in on these: the knob must keep a visible ring of track on every side in
both states. It reads as overflowing the track once that ring drops below a device pixel.
</div>
<div class="flex flex-col gap-2">
{#each toggleSizes as size}
<div class="flex flex-row items-center gap-6">
{#each subpixelOffsets as offset}
<div style="padding-left: {offset}px">
<Toggle {size} checked={toggleChecked} disabled={toggleDisabled} />
</div>
{/each}
</div>
{/each}
</div>
</div>
</TabContent>
<TabContent value="markdown" class="p-4">
<div class="text-xs text-tertiary mb-3">
The three <code>markdownProse</code> presets, same source. Each is sized for its own