Lens range

Pro

A range on a fine scale that magnifies like a dock under each thumb, with magnetic presets and value bubbles that fuse into one when they meet.

Live · keyboard ready

Senior product designer

Berlin, full time
Salary range

The top of this band beats 79% of offers for this role and covers 48% of the market.

  • Setting a salary band, budget or price range against a few meaningful reference values.
  • Ranges wide enough that the exact value under the finger needs magnifying, with a scale of fine ticks.
  • A settings or pricing page where the control should feel precise and alive.
  • Use slider for a plain single value or a range in dense forms.
  • Use ruler-slider for a single value picked on a moving tape under a fixed needle.
  • Use number-field when exact values are typed rather than explored.

Installation

Pro source and install commands unlock with a Pro plan.

example.tsx
import { useState } from "react";import { LensRange } from "@/components/arc/lens-range/lens-range"; const PRESETS = [  { value: 62_000, label: "Entry" },  { value: 82_000, label: "Median" },  { value: 104_000, label: "Top quarter" },]; export function SalaryBand() {  const [band, setBand] = useState<[number, number]>([74_000, 96_000]);   return (    <LensRange      label="Salary range"      min={40_000}      max={160_000}      step={1000}      value={band}      onValueChange={setBand}      presets={PRESETS}      format={value => `€${Math.round(value / 1000)}k`}      thumbLabels={["Minimum salary", "Maximum salary"]}    />  );}

LensRange

A two thumb range on a fine scale that works like a magnifying lens. Ticks near a thumb spread apart and swell like icons in a dock, more while it is hovered and most while it is dragged, and the ticks inside the range take the accent. Presets are soft detents: a dragged thumb is drawn in, held, and has to be pulled free. Value bubbles follow the thumbs on a trailing spring, stretch to fit their text and fuse into one pill when they meet.

PropTypeDefaultDescription
labelstring"Range"Visible label above the scale. It names the group and, unless thumbLabels is set, the two thumbs.
value[number, number]–Controlled range. Pair with onValueChange.
defaultValue[number, number]the middle halfStarting range when uncontrolled.
onValueChange(value: [number, number]) => void–Runs on every change while dragging, pressing keys or choosing a preset.
onValueCommit(value: [number, number]) => void–Runs once a drag is released, a key changes the value or a preset is pressed.
minnumber0Lowest value.
maxnumber100Highest value.
stepnumber1Values snap to this step, counted from min. A preset may sit between steps and is then taken exactly.
largeStepnumbera tenth of the spanHow far Page Up, Page Down and Shift with an arrow move a thumb, rounded to a step.
minStepsBetweenThumbsnumber0The closest the two values may sit, in steps.
presetsreadonly { value: number; label: string }[][]Values with a soft detent, shown as stops on the track and labels under it. Pressing a label moves the nearest thumb there.
magnetnumber16How far a preset reaches, in px. Inside it the thumb lingers near the preset and lets go at the edge. 0 turns the detents off.
format(value: number) => string–Formats the bubbles, the preset names and the spoken values, for example value => `€${value / 1000}k`.
thumbLabels[string, string][`${label}, minimum`, `${label}, maximum`]Accessible names of the two thumbs.
accentstring–Any CSS color for the ticks inside the range and the held preset dot. Defaults to the selection control color.
namestring–Submits the range with a form as two hidden inputs of this name, minimum first.
disabledbooleanfalseDims the control and ignores pointer, keys and presets.
classNamestring–Class on the root element.
styleCSSProperties–Inline styles on the root element.
Tab
Moves focus to the minimum thumb, the maximum thumb, then each preset label.
Arrow RightorArrow Up
Raises the focused thumb by one step.
Arrow LeftorArrow Down
Lowers the focused thumb by one step.
Shift+Arrow, Page UporPage Down
Moves the focused thumb by the large step.
HomeorEnd
Moves the focused thumb to its lowest or highest allowed value.
EnterorSpace on a preset
Moves the nearest thumb onto that preset.
  • The component is a group named by label. Each thumb is a focusable role="slider" with its own name, aria-valuemin and aria-valuemax that stop at the other thumb, aria-valuenow and a formatted aria-valuetext.
  • Preset labels are real buttons named with their label and value, such as Median, €82k, so the detents are reachable without a pointer.
  • The ticks, bubbles and stops are decorative and hidden from assistive technology; the sliders carry every value.
  • A focused thumb also opens its lens, so keyboard users get the same magnified reading as pointer users. Pointer focus draws no ring; keyboard focus shows Arc's shared focus ring.
  • Thumbs never cross, and a detent never pulls a thumb past the other one.
  • One requestAnimationFrame loop steps every spring at 240 Hz and writes transforms and opacity straight to the DOM through a cache; it stops at rest, offscreen and in background tabs.
  • The lens is a fisheye: each tick is displaced by a Gaussian weighted offset from each thumb, which spreads ticks near it and gathers them further out without ever reordering them, and swells them with a narrower Gaussian. Its strength springs from a quiet resting lens to a stronger one on hover or focus and the strongest while dragging.
  • Inside a preset's reach the thumb follows a power curve: it lingers near the preset, the value takes the preset exactly, and it lets go with a small jump at the edge. Catching a preset pulses its label and gives a short vibration on touch devices that support it.
  • Each bubble trails its thumb on a slightly underdamped spring while its pointer stays on the thumb, so the tail leans as the thumb moves. Pills are built from two caps and a stretched middle, so a new width springs instead of jumping.
  • When the bubbles meet they slide together, a bridge grows between them from the center line and the two values crossfade into one range, then split the same way.
  • A press on the scale moves the value under the finger at once while the thumb glides there.
  • With reduced motion the thumbs, bubbles and lens jump to their final state, there is no trailing, pulse or glide, and the detents still take the preset values.
  • The number of ticks follows the width, about one every 7px, so the scale keeps its density from 320px up.
  • Bubbles stay inside the component at either end, and preset labels are nudged apart when they would overlap at narrow widths while their stops stay on the exact values.
  • The scale takes the press with touch-action pan-y, so vertical page scroll still works; each thumb has a 44px hit area.
  • Each frame writes one transform per tick plus the thumbs and bubble pieces, all compositor friendly; nothing relayouts while dragging.
  • Text widths are read only when a bubble's text changes, label widths only on resize.
  • The loop idles at rest and pauses offscreen with an IntersectionObserver and in background tabs.

Notes for AI

Give your coding assistant the Markdown reference instead of screenshots.

  • Choose it for a range where reference points matter, such as a salary band against market data, a budget against plan limits or a price against competitor prices. Two to four presets read best.
  • Pass format for units; keep formatted values short, like €82k, because they sit in bubbles above the thumbs.
  • Preset values do not need to sit on a step; a held thumb takes the preset value exactly.
  • Put network requests in onValueCommit. Changing format, for example between yearly and monthly amounts, updates the bubbles and their widths spring to fit.

The full library index for assistants is at /llms.txt.