Saltar al contenido
ssntl/ui

Slider

Un slider que siempre muestra su valor y sus extremos, con soporte completo de teclado.

Delivery radius
Discount

Instalación

$ pnpm dlx shadcn@latest add @ssntl/slider

Uso

tsx
import { Slider } from "@/components/ui/slider"

<Slider label="Radio de entrega" min={1} max={30} defaultValue={8} formatValue={(km) => `${km} km`} />
  • Valor claro: el valor actual, con su unidad, va junto a la etiqueta; los dos extremos se rotulan bajo la barra.
  • formatValue también define aria-valuetext, así los lectores de pantalla dicen “8 km” y no “8”.
  • Las flechas, Re Pág/Av Pág, Inicio y Fin mueven el control (Radix). Usa un slider para valores aproximados; los exactos se escriben.

Código

components/ui/slider.tsx
"use client"

import * as React from "react"
import { Slider as SliderPrimitive } from "radix-ui"

import { cn } from "@/lib/utils"

// Value clarity: a slider always says where it is. The label and the current
// value share a row above the track, and the ends are labelled below, so
// nobody has to drag to find out what a position means. Radix handles the
// keyboard (arrows, Page Up/Down, Home, End) and aria-valuenow.
function Slider({
  label,
  formatValue = String,
  min = 0,
  max = 100,
  value,
  defaultValue,
  onValueChange,
  className,
  ...props
}: Omit<
  React.ComponentProps<typeof SliderPrimitive.Root>,
  "value" | "defaultValue" | "onValueChange"
> & {
  /** The visible label; it also names the thumb. */
  label: string
  /** How the value reads, with its unit: (v) => `${v} years`. */
  formatValue?: (value: number) => string
  value?: number
  defaultValue?: number
  onValueChange?: (value: number) => void
}) {
  const id = React.useId()
  const [inner, setInner] = React.useState(defaultValue ?? min)
  const current = value ?? inner

  return (
    <div data-slot="slider" className={cn("grid gap-3", className)}>
      <div className="flex items-baseline justify-between gap-4">
        <span id={id} className="text-label">
          {label}
        </span>
        <output aria-hidden className="text-label font-medium tabular-nums">
          {formatValue(current)}
        </output>
      </div>
      <SliderPrimitive.Root
        min={min}
        max={max}
        value={[current]}
        onValueChange={([next = min]) => {
          setInner(next)
          onValueChange?.(next)
        }}
        className="relative flex h-6 touch-none items-center select-none data-disabled:opacity-40"
        {...props}
      >
        <SliderPrimitive.Track className="relative h-1.5 grow overflow-hidden rounded-full bg-secondary">
          <SliderPrimitive.Range className="absolute h-full bg-primary" />
        </SliderPrimitive.Track>
        <SliderPrimitive.Thumb
          aria-labelledby={id}
          aria-valuetext={formatValue(current)}
          className="block size-6 rounded-full border-2 border-primary bg-card shadow-float transition-[scale] duration-(--duration-fast) ease-out hover:scale-110 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring active:scale-(--es-press) motion-reduce:transition-none"
        />
      </SliderPrimitive.Root>
      <div
        aria-hidden
        className="flex justify-between text-caption text-muted-foreground"
      >
        <span>{formatValue(min)}</span>
        <span>{formatValue(max)}</span>
      </div>
    </div>
  )
}

export { Slider }