Subframe
Components & interactionsElastic slider

Elastic fader

An orange level control stretches against its limits and settles back around a brushed-metal fill.

Drag the level past either endpoint, or use arrow keys.

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 { useEffect, useRef, useState } from "react"

export default function ElasticFader({ active = true }: { active?: boolean }) {
  const [value, setValue] = useState(64)
  const [stretch, setStretch] = useState(0)
  const [stretchOrigin, setStretchOrigin] = useState<"top" | "bottom">("bottom")
  const [dragging, setDragging] = useState(false)
  const [reduced, setReduced] = useState(true)
  const bounds = useRef<{ top: number; height: number } | null>(null)
  useEffect(() => {
    const q = matchMedia("(prefers-reduced-motion: reduce)")
    const update = () => setReduced(q.matches)
    update()
    q.addEventListener("change", update)
    return () => q.removeEventListener("change", update)
  }, [])
  useEffect(() => {
    if (!active) {
      bounds.current = null
      setDragging(false)
      setStretch(0)
    }
  }, [active])
  function update(y: number) {
    if (!bounds.current) return
    const raw = (1 - (y - bounds.current.top) / bounds.current.height) * 100
    setValue(Math.round(Math.min(100, Math.max(0, raw))))
    // Keep the fixed end anchored while the scale springs back after release.
    if (raw > 100) setStretchOrigin("bottom")
    else if (raw < 0) setStretchOrigin("top")
    setStretch(raw > 100 ? Math.log1p(raw - 100) * 0.045 : raw < 0 ? -Math.log1p(-raw) * 0.045 : 0)
  }
  function release() {
    bounds.current = null
    setDragging(false)
    setStretch(0)
  }
  return (
    <section className="sf-elastic-fader" data-still={!active || reduced}>
      <div className="sf-ef-label">
        <span>OUTPUT</span>
      </div>
      <div className="sf-ef-display">
        <strong>{String(value).padStart(2, "0")}</strong>
        <span>%</span>
      </div>
      <div className="sf-ef-control">
        <div className="sf-ef-scale" aria-hidden="true">
          {[100, 75, 50, 25, 0].map((v) => (
            <span key={v}>{v}</span>
          ))}
        </div>
        <div
          className="sf-ef-hit"
          role="slider"
          tabIndex={0}
          aria-label="Output level"
          aria-valuemin={0}
          aria-valuemax={100}
          aria-valuenow={value}
          aria-orientation="vertical"
          onPointerDown={(e) => {
            const b = e.currentTarget.getBoundingClientRect()
            bounds.current = { top: b.top, height: b.height }
            e.currentTarget.setPointerCapture(e.pointerId)
            setDragging(true)
            update(e.clientY)
          }}
          onPointerMove={(e) => update(e.clientY)}
          onPointerUp={release}
          onPointerCancel={release}
          onLostPointerCapture={release}
          onKeyDown={(e) => {
            const delta =
              e.key === "ArrowUp" || e.key === "ArrowRight"
                ? 1
                : e.key === "ArrowDown" || e.key === "ArrowLeft"
                ? -1
                : e.key === "PageUp"
                ? 10
                : e.key === "PageDown"
                ? -10
                : 0
            if (delta || e.key === "Home" || e.key === "End") {
              e.preventDefault()
              release()
              setValue((v) => (e.key === "Home" ? 0 : e.key === "End" ? 100 : Math.max(0, Math.min(100, v + delta))))
            }
          }}
        >
          <div
            className="sf-ef-shell"
            style={{
              transform: `scaleY(${1 + (active && !reduced ? Math.min(0.26, Math.abs(stretch)) : 0)})`,
              transformOrigin: stretchOrigin,
              transition: dragging ? "none" : undefined,
            }}
          >
            <div className="sf-ef-fill" style={{ transform: `scaleY(${value / 100})` }} />
            <div className="sf-ef-lines" />
            <span className="sf-ef-cap" style={{ bottom: `calc(${value}% - ${value * 0.32}px)` }}>
              <i />
              <i />
              <i />
            </span>
          </div>
        </div>
        <div className="sf-ef-ruler" aria-hidden="true" />
      </div>
      <style>{`.sf-elastic-fader{width:100%;height:100%;min-height:280px;background:#f35b22;color:#191916;font-family:Arial,sans-serif;position:relative;overflow:hidden;isolation:isolate;box-sizing:border-box;padding:24px 28px;container-type:inline-size}.sf-elastic-fader *{box-sizing:border-box}.sf-ef-label{display:flex;justify-content:space-between;font:10px monospace;letter-spacing:.12em;border-top:1px solid #2229;padding-top:9px}.sf-ef-display{position:absolute;left:8%;top:33%;display:flex;align-items:baseline;gap:4px}.sf-ef-display strong{font-size:clamp(64px,17cqw,105px);font-weight:500;letter-spacing:-.09em;line-height:1;font-variant-numeric:tabular-nums}.sf-ef-display>span{font:12px monospace;margin-left:7px}.sf-ef-control{position:absolute;right:13%;top:23%;height:59%;display:flex;gap:13px;align-items:stretch}.sf-ef-hit{position:relative;width:78px;cursor:ns-resize;touch-action:none;outline:none}.sf-ef-hit:focus-visible{outline:2px solid #191916;outline-offset:7px}.sf-ef-shell{position:absolute;inset:0;overflow:hidden;background:#161816;border:1px solid #252622;border-radius:9px;box-shadow:inset 0 3px 5px #0009,0 1px 0 #ffa975;transition:transform 380ms cubic-bezier(.18,1.5,.35,1)}.sf-ef-fill{position:absolute;inset:0;background:linear-gradient(90deg,#a0a7a8,#d5d9d7 35%,#bac2c3);transform-origin:bottom}.sf-ef-lines{position:absolute;inset:0;background:repeating-linear-gradient(0deg,transparent 0 12px,#0002 12px 13px)}.sf-ef-cap{position:absolute;left:5px;right:5px;height:32px;display:flex;flex-direction:column;justify-content:center;gap:3px;background:linear-gradient(#e9edeb,#aeb6b7);border-radius:4px;box-shadow:0 5px 7px #0007,inset 0 1px 0 #fff9;border:1px solid #8d989a}.sf-ef-cap i{margin:0 14px;height:1px;background:#7e898a;box-shadow:0 1px 0 #fff7}.sf-ef-scale{display:flex;flex-direction:column;justify-content:space-between;font:9px monospace;color:#391d11;text-align:right}.sf-ef-ruler{width:13px;background:repeating-linear-gradient(0deg,#492817 0 1px,transparent 1px 10%);opacity:.7}.sf-ef-foot{position:absolute;bottom:22px;left:28px;right:28px;display:flex;justify-content:space-between;font:10px monospace}.sf-elastic-fader[data-still=true] .sf-ef-shell{transition:none}@media(prefers-reduced-motion:reduce){.sf-ef-shell{transition:none}}@container(max-width:360px){.sf-ef-control{right:11%;gap:7px}.sf-ef-hit{width:66px}.sf-ef-display{left:6%}.sf-ef-display strong{font-size:65px}.sf-ef-display>span{font-size:10px;margin-left:2px}}`}</style>
    </section>
  )
}

Where it works well

Bounded continuous input with tactile overtravel.

Reduced motion

The level changes immediately; shell deformation is removed.

Things to know

A local 0–100 level, not an audio mixer.

More examples