import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { motion, AnimatePresence, useReducedMotion, LayoutGroup } from "framer-motion";
// A theme-aware input field with a $ / % segmented switch docked on its right edge.
// The active option is a motion.span pill that slides between options via layoutId
// and leans toward an inactive option on hover (the "peek"). Toggling re-presents
// the typed number with a leading $ (dollar) or a trailing % (percent), and the
// empty state shows a "0" placeholder.
//
// `value` holds the raw number as a string and `mode` ("usd" | "pct") says how to
// read it — lift both into your own form state when you wire this up.
// Detect a touch (coarse) pointer once on the client. SSR-safe: starts false (matching the
// server render) so it never causes a hydration mismatch, then flips after mount. Used only to
// add a hair of slide overshoot on touch and to keep the hover "peek" desktop-only.
function useCoarsePointer() {
const [coarse, setCoarse] = useState(false);
useEffect(() => {
const mq = window.matchMedia("(pointer: coarse)");
setCoarse(mq.matches);
const onChange = (e: MediaQueryListEvent) => setCoarse(e.matches);
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, []);
return coarse;
}
export default function InsetSwitchInput() {
const reduce = useReducedMotion();
const coarse = useCoarsePointer(); // touch device → bouncier slide + hover-peek disabled
const uid = useId();
const [value, setValue] = useState(""); // raw number: digits + at most one "."
const [mode, setMode] = useState<ModeId>("pct");
const [peek, setPeek] = useState(false); // inactive option hovered → active pill leans toward it
const inputRef = useRef<HTMLInputElement>(null);
const mirrorRef = useRef<HTMLSpanElement>(null);
const wrapRef = useRef<HTMLDivElement>(null);
const affixRef = useRef<HTMLSpanElement>(null);
const overflowRef = useRef(false);
const focusedRef = useRef(false);
const pendingSelectionRef = useRef<number | null>(null);
const segRefs = useRef<Record<ModeId, HTMLButtonElement | null>>({ usd: null, pct: null });
const modeIndex = (id: ModeId) => SEGMENTS.findIndex((s) => s.id === id);
const placeholder = "0";
// Auto-size the input to its content (or the "0" placeholder when empty) via a hidden
// mirror, so the affix hugs the number and the group stays left-aligned. Keyed on `mode`
// too — the active affix ($ vs %) has a different width, which shifts the overflow threshold.
const [inputW, setInputW] = useState(0);
const [formatTick, setFormatTick] = useState(0);
const [overflow, setOverflow] = useState(false);
const [focused, setFocused] = useState(false);
const [scrolled, setScrolled] = useState(false);
const displayValue = formatNumber(value);
// Two regimes. FITS: the input is sized to its content (today's behavior) so the affix
// hugs the number. OVERFLOWS: the input fills the track so the native input scrolls to
// keep the caret visible, and a soft edge fade (below) replaces the hard clip.
useLayoutEffect(() => {
const mirror = mirrorRef.current;
const wrap = wrapRef.current;
if (!mirror || !wrap) return;
const numberW = mirror.offsetWidth + 2;
const affixW = affixRef.current ? affixRef.current.offsetWidth : 0;
const naturalW = numberW + affixW + 4; // + the 4px flex gap
const isOverflow = naturalW > wrap.clientWidth;
setOverflow(isOverflow);
overflowRef.current = isOverflow;
setInputW(isOverflow ? wrap.clientWidth : numberW);
wrap.classList.toggle("is-overflow", isOverflow);
}, [displayValue, placeholder, mode]);
useLayoutEffect(() => {
const input = inputRef.current;
const selection = pendingSelectionRef.current;
if (!input || selection === null || document.activeElement !== input) return;
input.setSelectionRange(selection, selection);
pendingSelectionRef.current = null;
// setSelectionRange doesn't reliably scroll a controlled input to a caret at the very
// end while the value + width re-render are still settling — re-assert it next frame so
// the digit just typed stays visible (covers fast typing and paste, not just human pace).
if (selection >= input.value.length) {
requestAnimationFrame(() => {
const el = inputRef.current;
if (el && document.activeElement === el) el.scrollLeft = el.scrollWidth;
});
}
}, [displayValue, formatTick]);
// Soft edge fade — set CSS vars on the wrap imperatively (no re-render) from the input's
// live scroll position. Focused → fade the earlier digits on the LEFT (never the caret).
// Idle → fade the overrun on the RIGHT, toward the switch. The two sides are mutually
// exclusive. Read overflow/focused from refs so the raw scroll event stays correct.
const syncFade = useCallback(() => {
const wrap = wrapRef.current;
const input = inputRef.current;
if (!wrap || !input) return;
if (!overflowRef.current) {
wrap.style.setProperty("--isw-fade", "0");
setScrolled(false); // the "$" re-enters once the value fits from the start again
return;
}
const sl = input.scrollLeft;
if (focusedRef.current) {
wrap.classList.add("is-fade-left");
wrap.classList.remove("is-fade-right");
wrap.style.setProperty("--isw-fade", String(Math.min(sl / FADE_W, 1)));
} else {
const hiddenRight = input.scrollWidth - input.clientWidth - sl;
wrap.classList.add("is-fade-right");
wrap.classList.remove("is-fade-left");
wrap.style.setProperty("--isw-fade", String(Math.min(hiddenRight / FADE_W, 1)));
}
setScrolled(sl > 0); // hides the inline affix while scrolled (React bails if unchanged)
}, []);
// Re-sync after the width flip / value change commits — scroll metrics are only fresh
// post-layout, so never sync from onChange.
useLayoutEffect(() => {
syncFade();
}, [syncFade, inputW, displayValue, formatTick, mode, focused, overflow]);
function onInputFocus() {
setFocused(true);
focusedRef.current = true;
syncFade();
}
function onInputBlur() {
setFocused(false);
focusedRef.current = false;
if (inputRef.current) inputRef.current.scrollLeft = 0; // snap to the start for the idle hero
syncFade();
}
function selectMode(id: ModeId) {
setMode(id);
setPeek(false); // clear the lean before the pill slides
}
// Clicking anywhere in the field's chrome focuses the input (like native input
// padding) — except the switch, whose buttons handle their own clicks, and the
// input itself, so caret placement / text selection still works.
function onFieldMouseDown(e: React.MouseEvent) {
const el = e.target as HTMLElement;
if (el === inputRef.current || el.closest(".isw-seg")) return;
e.preventDefault();
inputRef.current?.focus();
}
// Mouse click selects the mode and returns focus to the input so typing
// continues; keyboard activation (detail === 0) leaves focus on the option.
function onSegClick(e: React.MouseEvent, id: ModeId) {
selectMode(id);
if (e.detail > 0) inputRef.current?.focus();
}
function onSegKeyDown(e: React.KeyboardEvent) {
const i = modeIndex(mode);
let next: ModeId | null = null;
if (e.key === "ArrowRight" || e.key === "ArrowDown") next = SEGMENTS[(i + 1) % SEGMENTS.length].id;
else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = SEGMENTS[(i - 1 + SEGMENTS.length) % SEGMENTS.length].id;
else if (e.key === "Home") next = SEGMENTS[0].id;
else if (e.key === "End") next = SEGMENTS[SEGMENTS.length - 1].id;
if (next) {
e.preventDefault();
selectMode(next);
segRefs.current[next]?.focus();
}
}
function onValueChange(e: React.ChangeEvent<HTMLInputElement>) {
const nextInput = e.currentTarget.value;
const rawCaret = rawIndexFromDisplay(nextInput, e.currentTarget.selectionStart ?? nextInput.length);
const nextValue = sanitize(nextInput);
const nextDisplay = formatNumber(nextValue);
pendingSelectionRef.current = caretFromRawIndex(nextDisplay, Math.min(rawCaret, nextValue.length));
setValue(nextValue);
if (nextValue === value && nextInput !== nextDisplay) setFormatTick((tick) => tick + 1);
}
// Reduced motion collapses every spring to an instant cut; otherwise duration/bounce per spring.
const spring = (duration: number, bounce = 0) =>
reduce ? { duration: 0 } : { type: "spring" as const, duration, bounce };
const pillSpring = spring(0.4, coarse ? 0.12 : 0);
const peekSpring = spring(0.6);
// Anticipatory lean: the active pill stretches ~1.6px toward a hovered option
// (32px pill × 0.05 ≈ 1.6px), anchored on its far edge.
const peekScale = reduce ? 1 : 1.05;
// The value box slide and each affix's x share ONE spring so they read as one coordinated slide
// (same physical metaphor as the switch pill). Opacity rides a separate FAST ease-in tween so the
// exiting glyph is gone before the value slides through its old slot. A brief blur rides that same
// fade to soften the dissolve into the backdrop, then returns to 0 — the glyph you read at rest
// stays crisp (no scale: that would resample the glyph and read as fuzz).
const slideSpring = spring(0.32);
const affixOpacity = reduce ? { duration: 0 } : { duration: 0.1, ease: [0.4, 0, 1, 1] as const };
// Asymmetric enter/exit slide — the $ slides left into the field's tight 12px padding gutter, so it
// travels a touch less (calmer against the edge); the % slides right into open track. The sign at
// the use site encodes direction. Both are distinct from USD_INDENT (12), the overflow-regime indent.
const PREFIX_SLIDE = 8; // $ → left
const SUFFIX_SLIDE = 10; // % → right
const AFFIX_BLUR = 4; // px — transitional blur at the fade peak; the resting state returns to blur(0px)
const affixBlur = `blur(${AFFIX_BLUR}px)`;
return (
<div className="isw-field" onMouseDown={onFieldMouseDown}>
<style>{styles}</style>
<div ref={wrapRef} className="isw-inputwrap">
<AnimatePresence initial={false} mode="popLayout">
{mode === "usd" && (
<motion.span
key="prefix"
ref={affixRef}
className="isw-affix-slot is-prefix"
initial={{ opacity: 0, x: -PREFIX_SLIDE, filter: affixBlur }}
animate={{ opacity: scrolled ? 0 : 1, x: scrolled ? -USD_INDENT : 0, filter: "blur(0px)" }}
exit={{ opacity: 0, x: -PREFIX_SLIDE, filter: affixBlur }}
transition={{ opacity: affixOpacity, x: slideSpring, filter: affixOpacity }}
aria-hidden="true"
>
$
</motion.span>
)}
</AnimatePresence>
<motion.div
layout={overflow ? false : "position"}
className="isw-value"
transition={{ layout: slideSpring }}
>
<input
ref={inputRef}
className="isw-input"
style={{
width: inputW,
textIndent: overflow && mode === "usd" ? USD_INDENT : undefined,
}}
value={displayValue}
onChange={onValueChange}
onScroll={syncFade}
onFocus={onInputFocus}
onBlur={onInputBlur}
placeholder={placeholder}
inputMode="decimal"
aria-label={mode === "usd" ? "Gain in dollars" : "Gain as a percentage"}
autoComplete="off"
spellCheck={false}
/>
</motion.div>
<AnimatePresence initial={false} mode="popLayout">
{mode === "pct" && (
<motion.span
key="suffix"
ref={affixRef}
className="isw-affix-slot is-suffix"
initial={{ opacity: 0, x: SUFFIX_SLIDE, filter: affixBlur }}
animate={{ opacity: overflow ? 0 : 1, x: 0, filter: "blur(0px)" }}
exit={{ opacity: 0, x: SUFFIX_SLIDE, filter: affixBlur }}
transition={{ opacity: affixOpacity, x: slideSpring, filter: affixOpacity }}
aria-hidden="true"
>
%
</motion.span>
)}
</AnimatePresence>
{/* Hidden mirror — measures the current text to auto-size the input. */}
<span ref={mirrorRef} className="isw-mirror" aria-hidden="true">
{displayValue || placeholder}
</span>
</div>
<LayoutGroup id={uid}>
<div className="isw-seg-group" role="radiogroup" aria-label="Amount type">
{SEGMENTS.map((seg) => {
const isActive = mode === seg.id;
return (
<button
key={seg.id}
ref={(el) => {
segRefs.current[seg.id] = el;
}}
type="button"
role="radio"
aria-checked={isActive}
aria-label={seg.label}
tabIndex={isActive ? 0 : -1}
className={`isw-seg${isActive ? " is-active" : ""}`}
onClick={(e) => onSegClick(e, seg.id)}
onMouseEnter={() => { if (!coarse) setPeek(!isActive); }}
onMouseLeave={() => { if (!coarse) setPeek(false); }}
onKeyDown={onSegKeyDown}
>
{isActive && (
<motion.span
layoutId="isw-pill"
className="isw-pill"
style={{ originX: modeIndex(mode) === 0 ? 0 : 1 }}
animate={{ scaleX: peek ? peekScale : 1 }}
transition={{ layout: pillSpring, scaleX: peekSpring }}
/>
)}
<span className="isw-seg-label">{seg.glyph}</span>
</button>
);
})}
</div>
</LayoutGroup>
</div>
);
}
// ----- Data -----
// Soft-fade band width in px. Must match --isw-fade-w in the styles below.
const FADE_W = 24;
// In overflow, usd's "$" lifts out of flow (absolute, left:0); this text-indent keeps the
// first digit clear of it at rest and matches the FITS "$"+gap offset (no jump at the
// threshold). text-indent (not padding) is part of the scrollable content, so the leading
// space scrolls away with the digits — no dead gutter once the "$" slides out.
const USD_INDENT = 12;
const SEGMENTS = [
{ id: "usd", glyph: "$", label: "Dollar amount" },
{ id: "pct", glyph: "%", label: "Percentage" },
] as const;
type ModeId = (typeof SEGMENTS)[number]["id"];
// Keep digits and at most one decimal point — caret-safe (no thousands regrouping).
function sanitize(s: string) {
const cleaned = s.replace(/[^\d.]/g, "");
const dot = cleaned.indexOf(".");
if (dot === -1) return cleaned;
return cleaned.slice(0, dot + 1) + cleaned.slice(dot + 1).replace(/\./g, "");
}
function formatNumber(raw: string) {
if (raw === "" || raw.startsWith(".")) return raw;
const [integer, ...rest] = raw.split(".");
const decimal = rest.length > 0 ? `.${rest.join("")}` : "";
return integer.replace(/\B(?=(\d{3})+(?!\d))/g, ",") + decimal;
}
function rawIndexFromDisplay(display: string, caret: number) {
return sanitize(display.slice(0, caret)).length;
}
function caretFromRawIndex(display: string, rawIndex: number) {
if (rawIndex === 0) return 0;
let seen = 0;
for (let i = 0; i < display.length; i++) {
if (/[\d.]/.test(display[i])) seen++;
if (seen === rawIndex) return i + 1;
}
return display.length;
}
// ----- Styles -----
const styles = `
@property --isw-fade { syntax: "<number>"; inherits: false; initial-value: 0; }
.isw-field {
/* Theme-aware tokens. The dark arm is the original design verbatim; the light
arm mirrors it onto the site's light canvas. No color-scheme pin here, so
light-dark() follows the visitor's theme (set on the page root). */
--isw-field-bg: light-dark(rgba(0, 0, 0, 0.02), rgba(0, 0, 0, 0.5));
/* Field depth — a layered box-shadow replaces the flat border: a hairline edge ring (the
single hard edge), then tight + soft drops that use NEGATIVE spread so the blur sits
beneath the panel instead of haloing the crisp edge (the old 0-spread drops read as haze
in light mode), and a SHARP 1px inset line for the recess (a blurred inset smudged the
interior). light-dark() only swaps a <color>, NOT a whole shadow (wrapping a full shadow
voids the property → box-shadow: none — the original --isw-pill-shadow had this latent
bug). Geometry is shared; per-layer color/opacity changes by theme: light rings in black,
dark rings in white (black vanishes on the dark fill) with higher-opacity drops. */
--isw-field-shadow:
0 0 0 1px light-dark(rgba(0, 0, 0, 0.08), rgba(255, 255, 255, 0.08)),
0 1px 2px -1px light-dark(rgba(0, 0, 0, 0.05), rgba(0, 0, 0, 0.4)),
0 4px 8px -4px light-dark(rgba(0, 0, 0, 0.06), rgba(0, 0, 0, 0.45)),
inset 0 1px 0 light-dark(rgba(0, 0, 0, 0.04), rgba(0, 0, 0, 0.3));
--isw-field-shadow-hover:
0 0 0 1px light-dark(rgba(0, 0, 0, 0.11), rgba(255, 255, 255, 0.12)),
0 1px 2px -1px light-dark(rgba(0, 0, 0, 0.06), rgba(0, 0, 0, 0.45)),
0 4px 9px -4px light-dark(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.5)),
inset 0 1px 0 light-dark(rgba(0, 0, 0, 0.05), rgba(0, 0, 0, 0.35));
--isw-field-shadow-focus:
0 0 0 1px light-dark(rgba(0, 0, 0, 0.17), rgba(255, 255, 255, 0.18)),
0 1px 2px -1px light-dark(rgba(0, 0, 0, 0.06), rgba(0, 0, 0, 0.45)),
0 4px 10px -4px light-dark(rgba(0, 0, 0, 0.09), rgba(0, 0, 0, 0.5)),
inset 0 1px 0 light-dark(rgba(0, 0, 0, 0.05), rgba(0, 0, 0, 0.35));
--isw-text: light-dark(#111, #fff);
--isw-affix: light-dark(rgba(0, 0, 0, 0.45), rgba(255, 255, 255, 0.55));
--isw-seg-idle: light-dark(rgba(0, 0, 0, 0.4), rgba(255, 255, 255, 0.5));
--isw-seg-hover: light-dark(rgba(0, 0, 0, 0.7), rgba(255, 255, 255, 0.8));
--isw-focus-ring: light-dark(rgba(0, 0, 0, 0.45), rgba(255, 255, 255, 0.6));
--isw-pill-bg: light-dark(#ffffff, #262626);
--isw-pill-border: light-dark(rgba(0, 0, 0, 0.1), rgba(255, 255, 255, 0.06));
/* Raised chip — OPAQUE fill (a translucent fill let the grey well bleed through → muddy),
a crisp 1px border as the single hard edge, two NEGATIVE-spread drops that sit beneath it
(not haloing the border → no fuzz), and a sharp 1px top-highlight line (reads mainly in
dark on the #262626 face). Shared geometry, per-theme color via light-dark() (see the field
note). Negative spread also keeps the shadow from shearing on overflow:clip when % sits at
the right edge. */
--isw-pill-shadow:
0 1px 2px -1px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.5)),
0 2px 4px -2px light-dark(rgba(0, 0, 0, 0.14), rgba(0, 0, 0, 0.55)),
inset 0 1px 0 light-dark(rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0.1));
display: flex;
align-items: center;
width: 192px;
max-width: 100%;
padding: 4px;
padding-left: 12px;
gap: 8px;
border-radius: 12px;
background: var(--isw-field-bg);
box-shadow: var(--isw-field-shadow);
overflow: clip;
font-family: var(--font-sans, system-ui, sans-serif);
transition: box-shadow 160ms ease;
}
/* Hover gated to real hover so touch devices don't get a stuck-hover shadow. */
@media (hover: hover) {
.isw-field:hover { box-shadow: var(--isw-field-shadow-hover); }
}
/* After :hover in source order so focus wins when both match (equal specificity). */
.isw-field:focus-within { box-shadow: var(--isw-field-shadow-focus); }
.isw-field *, .isw-field *::before, .isw-field *::after { box-sizing: border-box; }
/* ---- Input + affixes ---- */
.isw-inputwrap {
position: relative;
flex: 1 0 0;
min-width: 0;
display: flex;
align-items: center;
gap: 4px;
overflow: visible;
}
/* Hard clip + native scroll + edge-fade mask are the OVERFLOW regime's job (.is-overflow, below).
In FITS the wrap must NOT clip its left edge, so the $ prefix can slide ~10px into the field's
left padding — the field's own overflow:clip is the real boundary at x=0 — instead of being cut
at the wrap's flush edge. */
.isw-inputwrap.is-overflow { overflow: hidden; }
.isw-value {
display: flex;
align-items: center;
flex-shrink: 0;
}
/* The hidden mirror measures the input's text — these three MUST share metrics. */
.isw-input,
.isw-affix-slot,
.isw-mirror {
font-size: 14px;
line-height: 18px;
letter-spacing: -0.08px;
}
.isw-input {
flex-shrink: 0;
border: none;
outline: none;
background: transparent;
padding: 0;
margin: 0;
font-family: inherit;
font-weight: 500;
color: var(--isw-text);
caret-color: var(--isw-text);
}
.isw-input::placeholder { color: var(--isw-text); }
.isw-affix-slot {
flex-shrink: 0;
font-weight: 400;
color: var(--isw-affix);
}
.isw-affix-slot.is-prefix {
text-align: left;
}
.isw-affix-slot.is-suffix {
text-align: right;
}
.isw-mirror {
position: absolute;
left: -9999px;
top: 0;
visibility: hidden;
white-space: pre;
font-family: inherit;
font-weight: 500;
}
/* ---- Overflow regime: soft edge fade ---- */
/* The input fills the track and scrolls natively; the affix lifts out of flow so it can't
reserve width and re-clip the caret. usd keeps "$" at the start (hidden once scrolled);
pct drops "%" (it lives at the off-screen end — the switch carries the mode). */
.isw-inputwrap.is-overflow .isw-affix-slot {
position: absolute;
top: 0;
bottom: 0;
display: flex;
align-items: center;
}
.isw-inputwrap.is-overflow .isw-affix-slot.is-prefix { left: 0; }
.isw-inputwrap.is-overflow .isw-affix-slot.is-suffix { right: 0; }
/* A single 0..1 intensity (--isw-fade) drives whichever side has hidden overflow; @property
makes it animatable. --isw-fade-w must match FADE_W in the JS. Feature-gated so the FITS
path and unsupported browsers carry no mask at all (initial --isw-fade:0 → fully opaque →
degrades to today's hard edge, never a blank field). */
@supports (mask-image: linear-gradient(#000, #000)) or (-webkit-mask-image: linear-gradient(#000, #000)) {
.isw-inputwrap.is-overflow {
--isw-fade-w: 24px;
transition: --isw-fade 160ms ease;
}
.isw-inputwrap.is-overflow.is-fade-right {
-webkit-mask-image: linear-gradient(to right, #000 0, #000 calc(100% - var(--isw-fade-w) * var(--isw-fade)), transparent 100%);
mask-image: linear-gradient(to right, #000 0, #000 calc(100% - var(--isw-fade-w) * var(--isw-fade)), transparent 100%);
}
.isw-inputwrap.is-overflow.is-fade-left {
-webkit-mask-image: linear-gradient(to right, transparent 0, #000 calc(var(--isw-fade-w) * var(--isw-fade)), #000 100%);
mask-image: linear-gradient(to right, transparent 0, #000 calc(var(--isw-fade-w) * var(--isw-fade)), #000 100%);
}
}
/* ---- $ / % switch ---- */
.isw-seg-group { display: flex; align-items: center; gap: 2px; flex-shrink: 0; }
.isw-seg {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
border: none;
background: transparent;
border-radius: 9px;
cursor: pointer;
color: var(--isw-seg-idle);
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
transition: color 200ms ease;
}
.isw-seg.is-active { color: var(--isw-text); }
.isw-seg:not(.is-active):hover { color: var(--isw-seg-hover); }
.isw-seg:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--isw-focus-ring); }
.isw-seg-label {
position: relative;
z-index: 1;
font-size: 14px;
line-height: 20px;
font-weight: 500;
transition: transform 120ms cubic-bezier(0.4, 0, 0.2, 1);
}
/* Active option carries extra weight, per the design. */
.isw-seg.is-active .isw-seg-label { font-weight: 600; }
/* Tactile press: the glyph compresses inward on pointer-down. */
.isw-seg:active .isw-seg-label { transform: scale(0.85); }
.isw-pill {
position: absolute;
inset: 0;
z-index: 0;
border-radius: 9px;
background: var(--isw-pill-bg);
border: 1px solid var(--isw-pill-border);
box-shadow: var(--isw-pill-shadow);
}
/* Touch devices: iOS WebKit auto-zooms on focus of any input below 16px, and ClientRouter
keeps that zoom stuck across navigation. Bump the field's type to 16px so tapping the input
never zooms the page (pinch-zoom stays available). Input + mirror + affixes must move
together — the mirror sizes the input, the affix width feeds the overflow calc. */
@media (pointer: coarse) {
.isw-input,
.isw-affix-slot,
.isw-mirror,
.isw-seg-label {
font-size: 16px;
}
/* Comfort sizing — 44px touch targets, a wider field, softer corners. The auto-size mirror
measures at this scale from first render (pointer: coarse is static per device, so no JS). */
.isw-field {
width: 300px;
padding: 6px;
padding-left: 16px;
gap: 10px;
border-radius: 14px;
}
.isw-seg-group { gap: 3px; }
.isw-seg {
width: 44px;
height: 44px;
border-radius: 12px;
/* Spring-back on release for the press compression below (desktop's transition is untouched). */
transition: color 200ms ease, transform 140ms cubic-bezier(0.4, 0, 0.2, 1);
}
.isw-pill { border-radius: 12px; }
/* Tactile press: the whole chip compresses on pointer-down. The active pill renders inside the
active button, so it compresses with it — the "physical push." The glyph keeps its existing
0.85 squeeze, which nests with this for a touch more depth. */
.isw-seg:active { transform: scale(0.96); }
}
@media (prefers-reduced-motion: reduce) {
.isw-field *, .isw-field *::before, .isw-field *::after {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
}
.isw-seg:active .isw-seg-label { transform: none; }
.isw-seg:active { transform: none; }
}
`;