Ir al contenido
Evaluar mi proyecto
Componentes · FormulariosDisponible

Control segmentado

Opciones en una barra con una pastilla que se desliza con rebote.

Código completo

Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.

segmented-control.tsx90 líneas
Descargar
"use client";

/**
 * Control segmentado: opciones en una sola barra con una pastilla que se desliza al elegir.
 * Es un radiogroup: se navega con las flechas y se anuncia bien con lectores de pantalla.
 *
 *   <SegmentedControl
 *     label="Periodo"
 *     options={[{ value: "dia", label: "Día" }, { value: "semana", label: "Semana" }, { value: "mes", label: "Mes" }]}
 *     defaultValue="semana"
 *     onChange={(value) => setPeriod(value)}
 *   />
 */

import { useRef, useState, type KeyboardEvent } from "react";

export type SegmentedOption = { value: string; label: string; disabled?: boolean };

export type SegmentedControlProps = {
  options: SegmentedOption[];
  /** Nombre accesible del grupo. */
  label: string;
  value?: string;
  defaultValue?: string;
  onChange?: (value: string) => void;
  className?: string;
};

const CSS = `
.gcs-track { --gcs-count: 3; --gcs-index: 0; position: relative; display: inline-grid; grid-template-columns: repeat(var(--gcs-count), minmax(84px, 1fr)); padding: 4px; border-radius: 14px; background: #dadbe0; font-family: inherit; isolation: isolate; }
.gcs-thumb { position: absolute; top: 4px; bottom: 4px; left: 4px; z-index: -1; width: calc((100% - 8px) / var(--gcs-count)); border-radius: 10px; background: #111216; box-shadow: 0 3px 10px rgba(17, 18, 22, .35);
  transform: translateX(calc(var(--gcs-index) * 100%)); transition: transform .45s cubic-bezier(.34, 1.4, .5, 1); }
.gcs-option { min-height: 42px; padding: 0 18px; border: 0; border-radius: 10px; background: none; color: #3b3d46; font-family: inherit; font-size: 15px; font-weight: 600; line-height: 1; cursor: pointer; transition: color .25s; }
.gcs-option[aria-checked="true"] { color: #fff; }
.gcs-option:not([aria-checked="true"]):hover { color: #111216; }
.gcs-option:focus-visible { outline: 2px solid #4f46e5; outline-offset: 2px; }
.gcs-option:disabled { opacity: .45; cursor: not-allowed; }
@media (prefers-reduced-motion: reduce) { .gcs-thumb { transition: none; } .gcs-option { transition: none; } }
`;

export function SegmentedControl({ options, label, value, defaultValue, onChange, className }: SegmentedControlProps) {
  const [inner, setInner] = useState(defaultValue ?? options[0]?.value ?? "");
  const current = value ?? inner;
  const index = Math.max(0, options.findIndex((option) => option.value === current));
  const refs = useRef<(HTMLButtonElement | null)[]>([]);

  function select(next: string) {
    if (value === undefined) setInner(next);
    onChange?.(next);
  }

  function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
    const step = event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : event.key === "ArrowLeft" || event.key === "ArrowUp" ? -1 : 0;
    if (!step) return;
    event.preventDefault();
    for (let offset = 1; offset <= options.length; offset += 1) {
      const target = (index + step * offset + options.length * offset) % options.length;
      if (!options[target].disabled) {
        select(options[target].value);
        refs.current[target]?.focus();
        return;
      }
    }
  }

  return (
    <>
      <style>{CSS}</style>
      <div role="radiogroup" aria-label={label} className={["gcs-track", className].filter(Boolean).join(" ")} style={{ ["--gcs-count" as string]: options.length, ["--gcs-index" as string]: index } as React.CSSProperties} onKeyDown={onKeyDown}>
        <span className="gcs-thumb" aria-hidden="true" />
        {options.map((option, position) => (
          <button
            key={option.value}
            ref={(element) => { refs.current[position] = element; }}
            type="button"
            role="radio"
            aria-checked={option.value === current}
            tabIndex={option.value === current ? 0 : -1}
            className="gcs-option"
            disabled={option.disabled}
            onClick={() => select(option.value)}
          >
            {option.label}
          </button>
        ))}
      </div>
    </>
  );
}

Úsalo en tu proyecto

Instálalo con la CLI de shadcn.

terminal.sh1 líneas
npx shadcn@latest add https://www.gassierconsulting.com/r/control-segmentado

Ejemplo de uso de Control segmentado

Después de copiar segmented-control.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.

ejemplo.tsx5 líneas
import { SegmentedControl } from "./segmented-control";

export default function Ejemplo() {
  return <SegmentedControl label="Ejemplo" />;
}

Propiedades de SegmentedControl

SegmentedControl acepta 6 propiedades; 2 son obligatorias.

PropiedadTipoDescripción
optionsSegmentedOption[]Obligatoria.
labelstringNombre accesible del grupo.
value?stringOpcional.
defaultValue?stringOpcional.
onChange?(value: string) => voidOpcional.
className?stringOpcional.

Accesibilidad y rendimiento

El archivo tiene 90 líneas (4 KB) y no necesita @keyframes. Esto es lo que hace Control segmentado por quien lo usa:

  • Detecta prefers-reduced-motion y baja o quita el movimiento cuando la persona lo pide.
  • Muestra un anillo de foco visible al navegar con teclado (:focus-visible).
  • Declara roles ARIA para lectores de pantalla: radiogroup, radio.
  • Responde al teclado, no solo al ratón.
  • Es un componente de cliente de Next.js: incluye la directiva "use client".
Licencia
MIT
Dependencias
React >=18
Compatibilidad
React · Next.js (quita la directiva use client fuera de Next.js) · Sin Tailwind

Acerca del producto

Un selector de una opción entre pocas: una pastilla oscura se desliza con un ligero rebote hacia la elegida. Es un radiogroup completo: flechas para moverse, foco gestionado y opciones deshabilitables.

Incluye

Un solo archivo React + TypeScript, con los estilos dentro. No usa Tailwind ni bibliotecas externas.

  • Controlado o no controlado
  • Navegación con flechas (roving tabindex)
  • Opciones deshabilitables
  • Cualquier número de opciones

Cómo usarlo

Pasa options, label y defaultValue o value con onChange.