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.
"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.
npx shadcn@latest add https://www.gassierconsulting.com/r/control-segmentadoEjemplo de uso de Control segmentado
Después de copiar segmented-control.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.
import { SegmentedControl } from "./segmented-control";
export default function Ejemplo() {
return <SegmentedControl label="Ejemplo" />;
}Propiedades de SegmentedControl
SegmentedControl acepta 6 propiedades; 2 son obligatorias.
| Propiedad | Tipo | Descripción |
|---|---|---|
options | SegmentedOption[] | Obligatoria. |
label | string | Nombre accesible del grupo. |
value? | string | Opcional. |
defaultValue? | string | Opcional. |
onChange? | (value: string) => void | Opcional. |
className? | string | Opcional. |
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".
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.