Inputs

Dual Range Slider

A dual range slider: two native ranges share one track, only the span between the thumbs is filled, and the values cannot cross each other.

  • slider
  • range
  • filter
  • price

Preview

1440pxHost theme

Use it with AI

  1. 1. Copy the link.
  2. 2. Write to your agent in your own words and drop the link into the sentence.
  3. 3. The agent opens the link and installs the component from the registry.

put this in the header: https://vibeui.ru/c/slider-004?lang=en

Цена400014000

020000

Show the full instructions

What the link says, spelled out. Use it when your agent cannot open links — paste this text instead.

# Install and place "slider-004" (Dual Range Slider) from VibeUI

## 1. Install first — do not skip, do not recreate
Run this exact command before writing any code:
npx shadcn@latest add https://vibeui.ru/r/slider-004.json

Registry item: https://vibeui.ru/r/slider-004.json
Installs to: components/vibeui/slider-004.tsx (the exact path follows this project's components.json aliases).
npm dependencies: none.
Registry dependencies: none.

Install it from the registry. Do not recreate it from the description,
do not substitute a similar component from another library, and do not
rewrite it to match the project's existing style.

## 2. What it is
A dual range slider: two native ranges share one track, only the span between the thumbs is filled, and the values cannot cross each other.

A range built from two native inputs on a shared track with a filled span. One file, zero dependencies, a client component.

## 3. How to use it
import { Slider004 } from "@/components/vibeui/slider-004"

<Slider004
  label="Price"
  defaultFrom={4000}
  defaultTo={14000}
/>

Read the installed file for the full prop list.

## 4. Where to place it
This is a small inline component. Put it exactly where the user asked,
inside the existing markup. Do not create a new page, section or
wrapper for it. If a similar control already sits in that spot,
replace it instead of adding a second one.

Placement: ___
(The user fills this line in. If it is still blank, ask where to put it
instead of guessing.)

## 5. Keep exactly as installed
- pointer events restricted to the thumbs: otherwise the upper input covers the lower one and the second thumb is uncatchable
- the local --vibeui-slider-004-* palette — do not swap it for your theme tokens
- the values clamping each other by one step — otherwise the bounds swap and the range goes negative
- the single shared track as its own layer: two native tracks show a double stripe where they overlap
- distinct aria-labels on the two thumbs: "from" and "to" have to differ by ear
- the component's own light surface — without it the dark text disappears on a dark page

## 6. You may change
- the caption through label and the scale bounds through min and max
- the initial span through defaultFrom and defaultTo
- the unit of measure through unit
- the increment through step

## 7. Rules
- The thumbs cannot touch: the minimum gap equals the step, otherwise they stick together and never part.
- On touch the thumbs overlap in the middle of the scale — enlarge them if the component targets phones.
- The range is submitted as two form fields: the server still has to re-check their order.
- Do not lift the CSS variables into globals.css: the component has to stay a single file.

## 8. Verify
- it renders with no console errors;
- it looks like the preview on the VibeUI page you copied this from;
- if it does not, you changed something listed in section 5 — put it back.

For developers

npx shadcn@latest add https://vibeui.ru/r/slider-004.json
https://vibeui.ru/r/slider-004.json

Клиентский компонент: useState на нижнюю и верхнюю границу, каждая ограничивает соседнюю на шаг при вводе. Дорожка нарисована один раз отдельным слоем — иначе на пересечении видно две полосы. Оба input'а прозрачны и получают pointer-events:none, а обратно события включаются только на псевдоэлементах ручек: без этого верхний input перекрывал бы нижний по всей ширине. Палитра в --vibeui-slider-004-*.

Component source

The same file your agent installs. Here in case you would rather copy it by hand.

"use client"

import { useId, useState } from "react"
import type { ComponentPropsWithoutRef, CSSProperties } from "react"

export type Slider004Props = Omit<
  ComponentPropsWithoutRef<"div">,
  "children" | "defaultValue" | "onChange"
> & {
  label?: string
  min?: number
  max?: number
  step?: number
  defaultFrom?: number
  defaultTo?: number
  unit?: string
  accent?: string
}

// Идея компонента: диапазон двумя нативными range поверх одной дорожки.
// Второго типа input'а для диапазона в браузере нет, а самодельные ручки
// теряют клавиатуру и касания. Здесь оба input'а прозрачны, дорожка одна,
// закрашен только промежуток между ручками, а значения не могут
// перепрыгнуть друг друга: каждое ограничивает соседа при вводе.
const STYLES = `
:where([data-vibeui-block="slider-004"]){
--vibeui-slider-004-bg:oklch(1 0 0);
--vibeui-slider-004-fg:oklch(0.22 0.014 265);
--vibeui-slider-004-muted:oklch(0.55 0.014 265);
--vibeui-slider-004-border:oklch(0.9 0.006 265);
--vibeui-slider-004-track:oklch(0.92 0.006 265);
--vibeui-slider-004-accent:oklch(0.55 0.19 262);
--vibeui-slider-004-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
--vibeui-slider-004-from:20%;
--vibeui-slider-004-to:70%;
}
[data-vibeui-block="slider-004"]{
display:flex;flex-direction:column;gap:0.5rem;
width:100%;max-width:21rem;box-sizing:border-box;padding:0.875rem;
background:var(--vibeui-slider-004-bg);
border:1px solid var(--vibeui-slider-004-border);border-radius:0.875rem;
font-family:var(--vibeui-slider-004-font);color:var(--vibeui-slider-004-fg);
}
[data-vibeui-block="slider-004"] [data-part="head"]{
display:flex;align-items:baseline;justify-content:space-between;gap:1rem;font-size:0.8125rem;
}
[data-vibeui-block="slider-004"] [data-part="title"]{font-weight:600}
[data-vibeui-block="slider-004"] [data-part="value"]{font-weight:650;font-variant-numeric:tabular-nums}
/* Дорожка нарисована один раз, а не каждым input'ом: иначе на пересечении
   ручек видно две полосы. Закрашен ровно промежуток между значениями. */
[data-vibeui-block="slider-004"] [data-part="rail"]{position:relative;height:1.5rem}
[data-vibeui-block="slider-004"] [data-part="line"]{
position:absolute;left:0;right:0;top:0.5625rem;height:0.375rem;
border-radius:9999px;pointer-events:none;
background:linear-gradient(to right,
var(--vibeui-slider-004-track) var(--vibeui-slider-004-from),
var(--vibeui-slider-004-accent) var(--vibeui-slider-004-from),
var(--vibeui-slider-004-accent) var(--vibeui-slider-004-to),
var(--vibeui-slider-004-track) var(--vibeui-slider-004-to));
}
[data-vibeui-block="slider-004"] input{
appearance:none;position:absolute;left:0;top:0;
width:100%;height:1.5rem;margin:0;background:none;
pointer-events:none;
}
[data-vibeui-block="slider-004"] input::-webkit-slider-runnable-track{height:1.5rem;background:transparent}
[data-vibeui-block="slider-004"] input::-moz-range-track{height:1.5rem;background:transparent}
/* Прозрачен весь input, кроме ручки: иначе верхний из двух перекрывал бы
   нижний по всей ширине и вторую ручку было бы не поймать. */
[data-vibeui-block="slider-004"] input::-webkit-slider-thumb{
appearance:none;pointer-events:auto;cursor:pointer;
width:1.125rem;height:1.125rem;margin-top:0.1875rem;border-radius:9999px;
background:var(--vibeui-slider-004-bg);border:3px solid var(--vibeui-slider-004-accent);
box-shadow:0 1px 4px oklch(0.2 0.02 265 / 28%);
}
[data-vibeui-block="slider-004"] input::-moz-range-thumb{
pointer-events:auto;cursor:pointer;box-sizing:border-box;
width:1.125rem;height:1.125rem;border-radius:9999px;
background:var(--vibeui-slider-004-bg);border:3px solid var(--vibeui-slider-004-accent);
}
[data-vibeui-block="slider-004"] input:focus-visible{outline:2px solid var(--vibeui-slider-004-accent);outline-offset:2px;border-radius:0.75rem}
[data-vibeui-block="slider-004"] [data-part="scale"]{
display:flex;justify-content:space-between;margin:0;
font-size:0.6875rem;color:var(--vibeui-slider-004-muted);font-variant-numeric:tabular-nums;
}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="slider-004"] *{animation:none!important;transition:none!important}}
`

/**
 * Двойной ползунок диапазона: два нативных range на одной дорожке.
 * Один файл, ноль зависимостей, собственная палитра.
 */
export function Slider004({
  label = "Цена",
  min = 0,
  max = 20000,
  step = 500,
  defaultFrom = 4000,
  defaultTo = 14000,
  unit = " ₽",
  accent,
  className,
  style,
  ...props
}: Slider004Props) {
  const id = useId()
  const [from, setFrom] = useState(defaultFrom)
  const [to, setTo] = useState(defaultTo)
  const percent = (value: number) => `${((value - min) / (max - min)) * 100}%`

  const palette = {
    "--vibeui-slider-004-from": percent(from),
    "--vibeui-slider-004-to": percent(to),
    ...(accent ? { "--vibeui-slider-004-accent": accent } : null),
    ...style,
  } as CSSProperties

  return (
    <>
      <style href="vibeui-slider-004" precedence="medium">
        {STYLES}
      </style>
      <div
        {...props}
        data-vibeui-block="slider-004"
        className={className}
        style={palette}
      >
        <p data-part="head">
          <span data-part="title">{label}</span>
          <span data-part="value" role="status">
            {from}
            {unit} — {to}
            {unit}
          </span>
        </p>
        <div data-part="rail">
          <span data-part="line" aria-hidden="true" />
          <input
            id={`${id}-from`}
            type="range"
            min={min}
            max={max}
            step={step}
            value={from}
            aria-label={`${label}: от`}
            onChange={(event) =>
              setFrom(Math.min(Number(event.target.value), to - step))
            }
          />
          <input
            id={`${id}-to`}
            type="range"
            min={min}
            max={max}
            step={step}
            value={to}
            aria-label={`${label}: до`}
            onChange={(event) =>
              setTo(Math.max(Number(event.target.value), from + step))
            }
          />
        </div>
        <p data-part="scale">
          <span>
            {min}
            {unit}
          </span>
          <span>
            {max}
            {unit}
          </span>
        </p>
      </div>
    </>
  )
}