Button Group
Une Button e Input en una sola pieza visual — el radio de 6px solo vive en las dos puntas, el borde compartido se solapa 1px en vez de duplicarse. No son componentes nuevos que aprender: son los mismos Button e Input de siempre, el grupo solo decide cómo se paran uno junto al otro. Con value, un fondo dorado se desliza al ítem activo en vez de aparecer de golpe.
Playground
<ButtonGroup value={active} onValueChange={setActive}>
<Button variant="ghost" data-value="dia">Día</Button>
<Button variant="ghost" data-value="semana">Semana</Button>
<Button variant="ghost" data-value="mes">Mes</Button>
</ButtonGroup>Indicador deslizante
Con value + onValueChange, el grupo mide de verdad la posición y el ancho del ítem activo (getBoundingClientRect, no un cálculo aproximado) y desliza un fondo plano hasta ahí — sin blur, sin brillo, mismo tono sólido que el resto de la paleta. El indicador va detrás del botón, así que necesita que el botón no tape su propio fondo: variant="ghost" ya es transparente; variant="outline" funciona agregando className="bg-transparent" (pisa su bg-background propio vía tailwind-merge). variant="primary" no aplica aquí — ya es dorado sólido siempre, no hay nada que el indicador pueda revelar; para ese caso ver la alternativa sin indicador más abajo.
variant="ghost" — recomendado
variant="outline" — con className="bg-transparent"
const [active, setActive] = useState("semana");
<ButtonGroup value={active} onValueChange={setActive}>
<Button variant="ghost" data-value="dia">Día</Button>
<Button variant="ghost" data-value="semana">Semana</Button>
<Button variant="ghost" data-value="mes">Mes</Button>
</ButtonGroup>
<Button variant="outline" className="bg-transparent" data-value="dia">Día</Button>
{/* outline trae bg-background propio — sin el override, tapa el indicador que va detrás */}Sin indicador
El patrón simple, sin medición ni animación: cada click cambia el variant del botón a mano (primary el activo, outline el resto). Menos código, cero JS de posicionamiento — la opción correcta cuando no hace falta el gesto deslizante.
const [active, setActive] = useState("semana");
<ButtonGroup>
<Button variant={active === "dia" ? "primary" : "outline"} onClick={() => setActive("dia")}>Día</Button>
<Button variant={active === "semana" ? "primary" : "outline"} onClick={() => setActive("semana")}>Semana</Button>
<Button variant={active === "mes" ? "primary" : "outline"} onClick={() => setActive("mes")}>Mes</Button>
</ButtonGroup>
{/* variant="primary" ya es dorado sólido siempre — no hay nada que el indicador pudiera revelar ahí, por eso este patrón no lo usa */}Con dropdown
ButtonGroupDropdown es el "botón partido" — un chevron pegado al último ítem que abre un menú con lo que no entra en la acción principal. Misma mecánica de clic-afuera / Esc que el … de Breadcrumb — comparten el mismo hook por dentro.
import { Button, ButtonGroup, ButtonGroupDropdown, ButtonGroupDropdownItem } from "./components/ui/button-group";
<ButtonGroup>
<Button>Guardar</Button>
<ButtonGroupDropdown aria-label="Más opciones para guardar">
<ButtonGroupDropdownItem onClick={guardarComo}>Guardar como…</ButtonGroupDropdownItem>
<ButtonGroupDropdownItem onClick={guardarCopia}>Guardar una copia</ButtonGroupDropdownItem>
</ButtonGroupDropdown>
</ButtonGroup>Con Input
Un Input con ícono envuelve el campo real en un <div> — el grupo alcanza igual esa punta redondeada un nivel más adentro, no solo el borde exterior. El botón puede ser texto o iconOnly — mismo Button de siempre.
texto
iconOnly
import { Input, Button, ButtonGroup } from "./components/ui/button-group";
<ButtonGroup size="sm">
<Input leadingIcon={<SearchIcon />} placeholder="Buscar plantillas…" />
<Button>Buscar</Button>
</ButtonGroup>
<ButtonGroup size="sm">
<Input placeholder="Buscar plantillas…" />
<Button iconOnly aria-label="Buscar">
<SearchIcon />
</Button>
</ButtonGroup>Size heredado
size en ButtonGroup baja a cada Button/Input de adentro — mismo criterio que el size de AvatarGroup. Un ítem puntual puede seguir pisándolo con su propio size.
<ButtonGroup size="lg">
<Button variant="outline">Día</Button>
<Button variant="outline">Semana</Button>
</ButtonGroup>
{/* los Button de adentro no repiten size="lg" — lo heredan del grupo */}Código
"use client";
import * as React from "react";
import { cn } from "./lib/cn";
import { EASE } from "./lib/motion";
import { ButtonGroupContext, type ButtonGroupSize } from "./lib/button-group-context";
import { useDismiss } from "./lib/use-dismiss";
import { Button, type ButtonVariant } from "./button";
export type ButtonGroupOrientation = "horizontal" | "vertical";
// une Button/Input en una sola pieza visual: el radio de 6px solo vive en
// las dos puntas, el borde compartido se solapa 1px en vez de duplicarse,
// y el que tiene foco/hover sube de z-index para no quedar tapado por el
// vecino. [&>*_input] repite cada regla apuntando también un nivel más
// adentro porque Input con leadingIcon/trailingIcon envuelve el <input>
// real en un <div> — sin esa segunda regla, esos Input perderían la punta
// redondeada dentro del grupo.
const orientationVariants: Record<ButtonGroupOrientation, string> = {
horizontal:
"inline-flex flex-row items-stretch " +
"[&>*]:relative [&>*]:rounded-none [&>*_input]:rounded-none " +
"[&>*:first-child]:rounded-l-md [&>*:first-child_input]:rounded-l-md " +
"[&>*:last-child]:rounded-r-md [&>*:last-child_input]:rounded-r-md " +
"[&>*:not(:first-child)]:-ml-px " +
"[&>*:hover]:z-[1] [&>*:focus-within]:z-[1]",
vertical:
"inline-flex flex-col items-stretch " +
"[&>*]:relative [&>*]:rounded-none [&>*_input]:rounded-none " +
"[&>*:first-child]:rounded-t-md [&>*:first-child_input]:rounded-t-md " +
"[&>*:last-child]:rounded-b-md [&>*:last-child_input]:rounded-b-md " +
"[&>*:not(:first-child)]:-mt-px " +
"[&>*:hover]:z-[1] [&>*:focus-within]:z-[1]",
};
export interface ButtonGroupProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
orientation?: ButtonGroupOrientation;
/** Button/Input adentro heredan este size salvo que traigan uno propio */
size?: ButtonGroupSize;
/** activa el indicador deslizante — un fondo dorado plano que se desplaza al ítem cuyo data-value coincide */
value?: string;
onValueChange?: (value: string) => void;
children?: React.ReactNode;
}
export const ButtonGroup = React.forwardRef<HTMLDivElement, ButtonGroupProps>(
({ orientation = "horizontal", size, value, onValueChange, className, children, ...props }, ref) => {
const groupCtxValue = React.useMemo(() => ({ size }), [size]);
const outerRef = React.useRef<HTMLDivElement>(null);
const indicatorRef = React.useRef<HTMLDivElement>(null);
const activeElRef = React.useRef<HTMLElement | null>(null);
const mountedRef = React.useRef(false);
const reposition = React.useCallback(() => {
const outer = outerRef.current;
const indicator = indicatorRef.current;
if (!outer || !indicator || value === undefined) return;
let active: HTMLElement | null = null;
try {
active = outer.querySelector<HTMLElement>(`[data-value="${CSS.escape(value)}"]`);
} catch {
active = null;
}
activeElRef.current = active;
if (!active) {
indicator.style.opacity = "0";
return;
}
const outerRect = outer.getBoundingClientRect();
const activeRect = active.getBoundingClientRect();
const computed = getComputedStyle(active);
if (!mountedRef.current) indicator.style.transitionDuration = "0ms";
indicator.style.opacity = "1";
indicator.style.width = `${activeRect.width}px`;
indicator.style.height = `${activeRect.height}px`;
indicator.style.transform = `translate(${activeRect.left - outerRect.left}px, ${activeRect.top - outerRect.top}px)`;
indicator.style.borderRadius = computed.borderRadius;
if (!mountedRef.current) {
// fuerza el reflow para que el duration:0 se aplique antes de
// restaurarlo — si no, la primera posición también se anima
// (arranca visible desde la esquina y "vuela" hasta su lugar)
void indicator.offsetHeight;
indicator.style.transitionDuration = "";
mountedRef.current = true;
}
}, [value]);
React.useLayoutEffect(() => {
reposition();
}, [reposition, children]);
React.useEffect(() => {
if (value === undefined) return;
const onResize = () => reposition();
window.addEventListener("resize", onResize);
const ro = outerRef.current ? new ResizeObserver(onResize) : null;
ro?.observe(outerRef.current!);
return () => {
window.removeEventListener("resize", onResize);
ro?.disconnect();
};
}, [value, reposition]);
const enhancedChildren =
value === undefined && !onValueChange
? children
: React.Children.map(children, (child) => {
if (!React.isValidElement(child)) return child;
const childProps = child.props as { className?: string; onClick?: React.MouseEventHandler; "data-value"?: string };
const childValue = childProps["data-value"];
if (childValue === undefined) return child;
const isActive = value !== undefined && childValue === value;
return React.cloneElement(child as React.ReactElement<typeof childProps>, {
// en touch no hay mouse-out real: el botón activo puede
// quedar "pegado" en :hover después del tap, y el
// hover:bg-muted de ghost pintaba gris encima del indicador
// dorado, apagándolo. se anula solo en el activo — los
// demás conservan su hover normal.
className: cn(childProps.className, isActive && "text-primary-foreground hover:bg-transparent"),
onClick: (e: React.MouseEvent) => {
childProps.onClick?.(e as React.MouseEvent & React.MouseEvent<HTMLButtonElement>);
onValueChange?.(childValue);
},
});
});
return (
<ButtonGroupContext.Provider value={groupCtxValue}>
<div ref={outerRef} className="relative inline-flex">
{value !== undefined && (
<div
ref={indicatorRef}
aria-hidden="true"
className={cn("pointer-events-none absolute left-0 top-0 bg-primary opacity-0 transition-all duration-300", EASE)}
/>
)}
<div
ref={ref}
role="group"
className={cn(orientationVariants[orientation], value !== undefined && "relative", className)}
{...props}
>
{enhancedChildren}
</div>
</div>
</ButtonGroupContext.Provider>
);
},
);
ButtonGroup.displayName = "ButtonGroup";
function ChevronDownIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<path d="M6 9l6 6 6-6" />
</svg>
);
}
export interface ButtonGroupDropdownProps {
variant?: ButtonVariant;
"aria-label": string;
children?: React.ReactNode;
}
// el "botón partido": un chevron pegado al último ítem del grupo que abre
// un menú con las acciones que no entraron en la fila principal
export function ButtonGroupDropdown({ variant = "outline", "aria-label": ariaLabel, children }: ButtonGroupDropdownProps) {
const [open, setOpen] = React.useState(false);
const rootRef = React.useRef<HTMLDivElement>(null);
useDismiss(open, React.useCallback(() => setOpen(false), []), rootRef);
return (
<div ref={rootRef} className="relative flex">
<Button
type="button"
variant={variant}
iconOnly
aria-label={ariaLabel}
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen((o) => !o)}
className="rounded-[inherit]"
>
<ChevronDownIcon className="h-3.5 w-3.5" />
</Button>
<div
role="menu"
className={cn(
"absolute right-0 top-full z-20 mt-2 min-w-[10rem] origin-top-right rounded-md border border-border bg-background p-1 transition-all duration-150",
EASE,
open ? "pointer-events-auto scale-100 opacity-100" : "pointer-events-none scale-95 opacity-0",
)}
>
<div className="flex flex-col">{children}</div>
</div>
</div>
);
}
export interface ButtonGroupDropdownItemProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
export const ButtonGroupDropdownItem = React.forwardRef<HTMLButtonElement, ButtonGroupDropdownItemProps>(
({ className, ...props }, ref) => (
<button
ref={ref}
type="button"
role="menuitem"
className={cn(
"flex w-full items-center rounded-sm px-2 py-1.5 text-left text-sm text-foreground outline-none transition-colors duration-150 hover:bg-muted focus-visible:bg-muted",
className,
)}
{...props}
/>
),
);
ButtonGroupDropdownItem.displayName = "ButtonGroupDropdownItem";Código real de packages/ui/src/button-group.tsx.
Uso
import { Button, ButtonGroup } from "./components/ui/button-group";
<ButtonGroup>
<Button variant="outline">Día</Button>
<Button variant="outline">Semana</Button>
<Button variant="outline">Mes</Button>
</ButtonGroup>API
| Componente | Prop | Tipo | Default |
|---|---|---|---|
| ButtonGroup | orientation | horizontal | vertical | horizontal |
| size | xs | sm | md | lg | — | |
| value | string | — | |
| onValueChange | (value: string) => void | — | |
| ButtonGroupDropdown | variant | — (mismo set que Button) | outline |
| aria-label | string (requerido) | — | |
| ButtonGroupDropdownItem | — | atributos nativos de button | |
Sin prop size nueva en Button/Input — ya la tenían. Adentro de un grupo, ambos leen el contexto en vez de duplicar la prop.