supafast-ui · sub-skill: COMPONENTES
Componentes en src/shared/components/ui/. Headless = @base-ui/react (NO Radix).
Variantes = CVA. Composición de clases = cn() (clsx + tailwind-merge).
// src/shared/lib/utils.ts
export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) }
Idiom base-ui: useRender + mergeProps (en lugar de asChild)
Así se hace un componente polimórfico (el equivalente a asChild de Radix):
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/shared/lib/utils"
const buttonVariants = cva("…clases base…", {
variants: { variant: {…}, size: {…} },
defaultVariants: { variant: "default", size: "default" },
})
function Button({ className, variant, size, render, ...props }:
useRender.ComponentProps<"button"> & VariantProps<typeof buttonVariants>) {
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{ className: cn(buttonVariants({ variant, size, className })) },
props,
),
render,
state: { slot: "button", variant, size },
})
}
export { Button, buttonVariants }
Para renderizar como otra cosa (p.ej. un <Link>): se pasa render={<NavLink to=… />}.
Estados de base-ui: data-checked / data-unchecked
Las primitivas con estado (switch, toggle, checkbox…) exponen data-checked / data-unchecked,
no data-[state=checked]. Ejemplo real del Switch:
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
<SwitchPrimitive.Root
data-slot="switch"
className={cn("inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full p-0.5 shadow-xs transition-colors outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-50 data-checked:bg-primary data-unchecked:bg-input", className)}>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="pointer-events-none block size-4 rounded-full bg-background shadow-sm transition-transform data-unchecked:translate-x-0 data-checked:translate-x-4" />
</SwitchPrimitive.Root>
Recetas de clases (copia, no improvises)
Anatomía base de un control:
inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium outline-none transition-all disabled:pointer-events-none disabled:opacity-50
Focus ring (universal):
focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50
Estado inválido: aria-invalid:border-destructive aria-invalid:ring-destructive/20
Iconos lucide dentro de controles:
[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4
Variantes de Button (referencia)
default→bg-primary text-primary-foreground hover:bg-primary/90 border border-blackdestructive→bg-destructive text-white hover:bg-destructive/90outline→border border-border bg-background hover:bg-accent hover:text-accent-foregroundsecondary→bg-secondary text-secondary-foreground hover:bg-secondary/80ghost→hover:bg-accent hover:text-accent-foregroundlink→text-primary underline-offset-4 hover:underline- Tamaños:
xs / sm / default / lg / icon / icon-sm / icon-lg(ej.icon=size-9,default=px-5 py-2).
Variantes de Badge (referencia)
Pill rounded-4xl, h-5 text-xs font-medium, iconos size-3. Variante destructive usa fondo
tenue: bg-destructive/10 text-destructive (no fondo sólido). Tonos suaves para estados.
Reglas
- Componente nuevo → clona este idiom (useRender/mergeProps/cva). Nunca metas Radix.
- Variante nueva → extiende el
cvaexistente, no añadas clases sueltas en el call site. data-slot="…"en cada raíz para poder targetear desde fuera.- Iconos:
lucide-react,size-4por defecto.