Gap

No dependencies
Code format
React + Tailwind
import { useState } from 'react';

const css = `
  .slider-gap {
    position: relative;
    width: 240px;
    height: 64px;
    --slider-gap-x: calc(8px + var(--slider-gap-v) * (100% - 16px));
  }
  .slider-gap__on, .slider-gap__off { position: absolute; top: 24px; height: 16px; }
  .slider-gap__on { left: 0; width: max(0px, calc(var(--slider-gap-x) - 8px)); border-radius: 8px 3px 3px 8px; background: light-dark(#42454c, #e5e6e9); }
  .slider-gap__off { right: 0; width: max(0px, calc(100% - var(--slider-gap-x) - 8px)); border-radius: 3px 8px 8px 3px; background: light-dark(#ebedf0, #232529); }
  .slider-gap__stop { position: absolute; right: 6px; top: 30px; width: 4px; height: 4px; border-radius: 50%; background: light-dark(#5f626a, #a7abb2); transition: opacity 0.2s; }
  .slider-gap__handle {
    position: absolute;
    top: 10px;
    left: calc(var(--slider-gap-x) - 2px);
    width: 4px;
    height: 44px;
    border-radius: 2px;
    background: light-dark(#373a40, #f1f2f3);
    transition: transform 0.3s cubic-bezier(0.3, 1.6, 0.5, 1);
  }
  .slider-gap__pill {
    position: absolute;
    bottom: calc(100% - 4px);
    left: var(--slider-gap-x);
    min-width: 40px;
    height: 28px;
    padding: 0 10px;
    border-radius: 14px;
    background: light-dark(#373a40, #f1f2f3);
    color: light-dark(#fbfcfd, #121417);
    font-size: 13px;
    font-weight: 600;
    line-height: 28px;
    font-variant-numeric: tabular-nums;
    text-align: center;
    box-shadow: 0 4px 12px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.4));
    opacity: 0;
    transform-origin: 50% 100%;
    transform: translateX(-50%) translateY(8px) scale(0.4);
    transition: transform 0.32s cubic-bezier(0.3, 1.5, 0.5, 1), opacity 0.15s;
  }
  .slider-gap:has(.slider-gap__input:active) .slider-gap__handle { transform: scaleX(0.5); }
  .slider-gap:has(.slider-gap__input:active) .slider-gap__pill,
  .slider-gap:has(.slider-gap__input:focus-visible) .slider-gap__pill { opacity: 1; transform: translateX(-50%); }
  .slider-gap:has(.slider-gap__input:focus-visible) .slider-gap__handle { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
  .slider-gap__input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
  .slider-gap__input::-webkit-slider-thumb { -webkit-appearance: none; width: 16px; height: 64px; }
  .slider-gap__input::-moz-range-thumb { width: 16px; height: 64px; border: 0; }
  @media (prefers-reduced-motion: reduce) {
    .slider-gap__handle, .slider-gap__pill { transition: opacity 0.15s; }
    .slider-gap__pill { transform: translateX(-50%); }
  }
`;

export function GapSlider() {
  const [v, setV] = useState(40);
  return (
    <>
      <style>
        {css}
      </style>
      <div className="slider-gap" style={{ '--slider-gap-v': v / 100 } as React.CSSProperties}>
        <span className="slider-gap__on" aria-hidden="true" />
        <span className="slider-gap__off" aria-hidden="true" />
        {/* The stop dot steps aside before the handle reaches it. */}
        <span className="slider-gap__stop" style={{ opacity: v > 92 ? 0 : 1 }} aria-hidden="true" />
        <span className="slider-gap__handle" aria-hidden="true" />
        <span className="slider-gap__pill" aria-hidden="true">
          {v}
        </span>
        <input
          className="slider-gap__input"
          type="range"
          min={0}
          max={100}
          value={v}
          onChange={(e) => setV(+e.target.value)}
          aria-label="Brightness"
          aria-valuetext={`${v}%`}
        />
      </div>
    </>
  );
}

Free to use in any project, commercial included. MIT-0, no attribution required. Licence

Install with shadcn

npx shadcn@latest add @movin/slider-gap
Source on GitHub

About this component

A thick-track slider split by a slim handle with a gap either side of it: the handle thins as you press, the value rises above it in a pill, and a stop dot marks the end.

Dependencies
None
Difficulty
Beginner
Triggers
state change, continuous, spring
Techniques
css variables, css transition
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

A native range input with an aria-label and an aria-valuetext in percent; keyboard focus rings the handle and shows the value pill. Under prefers-reduced-motion the pill appears without the spring.

More sliders

See all
Glass
Glide
Halftone
Invert