Subframe
Components & interactionsTime series range slider

Chart range brush

Independent endpoints select listening days on one fixed-scale minimap.

Drag either minimap handle, or use arrow keys, Home/End and PageUp/PageDown. Escape cancels the drag.

Subframe Original · MIT

Add to your project

Component source

Standalone React component. Styles are included.

React only. No additional packages.

Render the default export inside a container with a defined height. Its optional active prop stops movement when false. This example works with React 19; Next.js projects can use it as a client component.

View source code
/**
 * MIT License
 * Copyright (c) 2026 Subframe
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */
"use client"
import * as React from "react"
const HOURS = [
  1.4, 2.1, 0.8, 3.4, 2.7, 1.2, 3.8, 4.2, 2.2, 1.7, 2.8, 4.5, 3.4, 2.1, 1.2, 3.6, 4.1, 2.5, 3.8, 1.4, 2.2, 3.1, 4.7,
  3.5, 2.1, 1.5, 2.6, 3.8, 4.4, 2.9, 1.8,
]
type End = "start" | "end"
export default function Example({ active = true }: { active?: boolean }) {
  const [range, setRange] = React.useState<[number, number]>([6, 23]),
    [keyboard, setKeyboard] = React.useState(false)
  const current = React.useRef(range),
    track = React.useRef<HTMLDivElement>(null)
  const gesture = React.useRef<{
    end: End
    pointer: number
    origin: [number, number]
    grab: number
    node: HTMLButtonElement
  } | null>(null)
  const update = (end: End, value: number) => {
    const [a, b] = current.current
    const next: [number, number] =
      end === "start" ? [Math.max(0, Math.min(b - 1, value)), b] : [a, Math.min(30, Math.max(a + 1, value))]
    current.current = next
    setRange(next)
  }
  const finish = React.useCallback((cancel: boolean) => {
    const g = gesture.current
    if (!g) return
    gesture.current = null
    if (cancel) {
      current.current = g.origin
      setRange(g.origin)
    }
    if (g.node.hasPointerCapture(g.pointer)) g.node.releasePointerCapture(g.pointer)
  }, [])
  React.useEffect(() => {
    if (!active) finish(true)
  }, [active, finish])
  React.useEffect(() => {
    const blur = () => finish(true)
    window.addEventListener("blur", blur)
    return () => window.removeEventListener("blur", blur)
  }, [finish])
  const [start, end] = range,
    total = HOURS.slice(start, end + 1).reduce((a, b) => a + b, 0)
  return (
    <section
      className="sf-chart-range-brush"
      data-still={!active || keyboard}
      onPointerDownCapture={() => setKeyboard(false)}
      onKeyDownCapture={(e) => {
        setKeyboard(true)
        if (e.key === "Escape") {
          e.preventDefault()
          finish(true)
        }
      }}
      aria-label="Listening history date range"
    >
      <div className="brush-chart">
        <header>
          <div>
            <span>Listening time</span>
            <strong>
              {total.toFixed(1)}
              <small>hours</small>
            </strong>
          </div>
          <span className="dates">
            Oct {start + 1}–{end + 1}
            <small>2026</small>
          </span>
        </header>
        <div className="bars" aria-hidden>
          {HOURS.map((hours, i) => (
            <i key={i} data-selected={i >= start && i <= end} style={{ height: (hours / 5) * 100 + "%" }} />
          ))}
        </div>
        <div className="axis" aria-hidden>
          <span>Oct 1</span>
          <span>15</span>
          <span>31</span>
        </div>
        <div className="brush" ref={track}>
          <div className="mini-bars" aria-hidden>
            {HOURS.map((hours, i) => (
              <i key={i} style={{ height: (hours / 5) * 100 + "%" }} />
            ))}
          </div>
          <div
            className="selected"
            aria-hidden
            style={{ left: `${(start / 30) * 100}%`, width: `${((end - start) / 30) * 100}%` }}
          />
          {(["start", "end"] as End[]).map((which) => (
            <button
              type="button"
              key={which}
              disabled={!active}
              className="handle"
              data-end={which}
              role="slider"
              aria-label={which === "start" ? "Range start day" : "Range end day"}
              aria-valuemin={which === "start" ? 1 : start + 2}
              aria-valuemax={which === "start" ? end : 31}
              aria-valuenow={(which === "start" ? start : end) + 1}
              aria-valuetext={`October ${(which === "start" ? start : end) + 1}, 2026`}
              style={{ left: `${((which === "start" ? start : end) / 30) * 100}%` }}
              onKeyDown={(e) => {
                const v = which === "start" ? start : end
                let next = v
                if (e.key === "Home") next = which === "start" ? 0 : start + 1
                else if (e.key === "End") next = which === "start" ? end - 1 : 30
                else if (["ArrowLeft", "ArrowDown"].includes(e.key)) next = v - 1
                else if (["ArrowRight", "ArrowUp"].includes(e.key)) next = v + 1
                else if (e.key === "PageUp") next = v + 7
                else if (e.key === "PageDown") next = v - 7
                else return
                e.preventDefault()
                update(which, next)
              }}
              onPointerDown={(e) => {
                if (!active || gesture.current || e.button !== 0 || !track.current) return
                e.preventDefault()
                const r = track.current.getBoundingClientRect(),
                  v = which === "start" ? start : end
                gesture.current = {
                  end: which,
                  pointer: e.pointerId,
                  origin: [...current.current],
                  grab: ((e.clientX - r.left) / r.width) * 30 - v,
                  node: e.currentTarget,
                }
                e.currentTarget.setPointerCapture(e.pointerId)
                e.currentTarget.focus({ preventScroll: true })
              }}
              onPointerMove={(e) => {
                const g = gesture.current
                if (!g || g.pointer !== e.pointerId || !track.current) return
                const r = track.current.getBoundingClientRect()
                update(g.end, Math.round(((e.clientX - r.left) / r.width) * 30 - g.grab))
              }}
              onPointerUp={(e) => {
                if (e.pointerId === gesture.current?.pointer) finish(false)
              }}
              onPointerCancel={(e) => {
                if (e.pointerId === gesture.current?.pointer) finish(true)
              }}
              onLostPointerCapture={(e) => {
                if (e.pointerId === gesture.current?.pointer) finish(true)
              }}
            >
              <span />
              <span />
            </button>
          ))}
        </div>
        <footer>
          <span>
            From <b>Oct {start + 1}</b>
          </span>
          <span>{end - start + 1} days</span>
          <span>
            To <b>Oct {end + 1}</b>
          </span>
        </footer>
        <div className="sr" role="status">
          October {start + 1} through {end + 1}: {total.toFixed(1)} listening hours.
        </div>
      </div>
      <style>{`
.sf-chart-range-brush{width:100%;height:100%;display:grid;place-items:center;background:#dde5ee;color:#273d54;font:12px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;overflow:hidden;-webkit-font-smoothing:antialiased}.sf-chart-range-brush *{box-sizing:border-box}.sf-chart-range-brush button{font:inherit;cursor:ew-resize;color:inherit;touch-action:none}.sf-chart-range-brush button:disabled{cursor:default}.sf-chart-range-brush :focus-visible{outline:2px solid #316dab;outline-offset:2px;border-radius:5px}.sf-chart-range-brush .brush-chart{width:480px;padding:22px 25px 17px;background:linear-gradient(#fff,#f9fbfe);border:1px solid #bdcddd;border-radius:13px;box-shadow:0 17px 32px -17px #2c4e7940,0 1px 3px #2143650c}.sf-chart-range-brush header{display:flex;justify-content:space-between;align-items:center}.sf-chart-range-brush header div>span{color:#7c8a99;font-size:11px}.sf-chart-range-brush header strong{display:flex;align-items:baseline;font-size:31px;font-weight:450;letter-spacing:-1px;margin-top:2px;font-variant-numeric:tabular-nums}.sf-chart-range-brush header strong small{font-size:11px;color:#8e9aa7;margin-left:6px;font-weight:400;letter-spacing:0}.sf-chart-range-brush .dates{text-align:right;font-size:11px;color:#4b6682}.sf-chart-range-brush .dates small{display:block;font-size:9px;color:#99a6b4;margin-top:3px}.sf-chart-range-brush .bars{height:100px;display:flex;align-items:flex-end;gap:5px;margin-top:16px;border-bottom:1px solid #dce5ef;background:repeating-linear-gradient(to top,transparent 0,transparent 24px,#e7edf46e 24px,#e7edf46e 25px)}.sf-chart-range-brush .bars i{flex:1;min-width:0;background:#d9e4ee;border-radius:2px 2px 0 0;transition:background 110ms}.sf-chart-range-brush .bars i[data-selected=true]{background:linear-gradient(#6a9fc6,#3975a1)}.sf-chart-range-brush .axis{display:flex;justify-content:space-between;color:#a5afba;font-size:9px;margin-top:5px}.sf-chart-range-brush .brush{height:36px;position:relative;margin:17px 0 0;background:#e8eff6;border:1px solid #d5e1ed;border-radius:5px;touch-action:none}.sf-chart-range-brush .mini-bars{position:absolute;inset:5px 0;display:flex;align-items:flex-end;gap:4px}.sf-chart-range-brush .mini-bars i{flex:1;min-width:0;background:#bbcfe1;border-radius:1px}.sf-chart-range-brush .selected{position:absolute;top:-1px;bottom:-1px;border-top:1px solid #6298c7;border-bottom:1px solid #6298c7;background:#8fbbdd26;pointer-events:none}.sf-chart-range-brush .handle{position:absolute;top:-6px;bottom:-6px;width:24px;transform:translateX(-50%);display:flex;align-items:center;justify-content:center;gap:2px;background:none;border:0;padding:0;z-index:2}.sf-chart-range-brush .handle:before{content:"";position:absolute;top:3px;bottom:3px;width:12px;border-radius:4px;background:linear-gradient(90deg,#fafdff,#dbeaf5);border:1px solid #83a9c8;box-shadow:0 1px 3px #2b527428,inset 1px 0 #fff;z-index:-1}.sf-chart-range-brush .handle span{height:12px;width:1px;background:#729abb}.sf-chart-range-brush .handle:active:before{background:#c5ddec}.sf-chart-range-brush footer{display:flex;align-items:center;justify-content:space-between;margin-top:13px;font-size:9px;color:#95a5b5}.sf-chart-range-brush footer b{font-weight:500;color:#617b94;margin-left:4px;font-variant-numeric:tabular-nums}.sf-chart-range-brush .sr{position:absolute;width:1px;height:1px;clip-path:inset(50%);overflow:hidden}.sf-chart-range-brush[data-still=true] *{transition:none!important}@media(prefers-reduced-motion:reduce){.sf-chart-range-brush *{transition:none!important}}`}</style>
    </section>
  )
}

Where it works well

Inspect a date interval without shifting the opposite endpoint or losing the full series.

Reduced motion

Finite transitions become immediate under reduced motion and keyboard input. Host pause disables controls; any owned drag is cancelled.

Things to know

Illustrative 31-day series. Minimum interval spans two inclusive days; endpoints cannot cross. No window panning, zoom or real listening data.

More examples