Saltar al contenido

Grupo de entradas

Añade iconos, botones y sugerencias de teclado a los campos de búsqueda, los campos de URL y los editores de chat, con bordes y sombras de estilo neobrutalista.

import { Search } from "lucide-react"

import {

El input group envuelve un input o un textarea junto con sus complementos —iconos, texto en línea, botones, pistas de teclado— dentro de un único control con borde. Es una composición propia de esta biblioteca a partir de Input, Textarea y Button, estilizada según la receta neobrutalista: bordes gruesos, sombras marcadas y tipografía en negrita.

Recurre a este componente cuando:

  • Campos de búsqueda — un icono de búsqueda al inicio más una pista ⌘K o un botón de limpiar al final.
  • Inputs con prefijo y sufijo — texto fijo https://, @ o .com para que el usuario escriba solo la parte variable.
  • Compositores de chat y prompts — un textarea con una barra de herramientas block-end de botones y una acción de envío.

Instalación

pnpm dlx shadcn@latest add https://neobrutalism.com/r/base/input-group.json
npx shadcn@latest add https://neobrutalism.com/r/base/input-group.json
yarn dlx shadcn@latest add https://neobrutalism.com/r/base/input-group.json
bunx --bun shadcn@latest add https://neobrutalism.com/r/base/input-group.json

Copia y pega el siguiente código en tu proyecto.

components/ui/input-group.tsx
"use client"

import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"

import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"

function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="input-group"
      role="group"
      className={cn(
        "group/input-group relative flex h-8 w-full min-w-0 items-center rounded border-2 bg-input shadow-sm transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:outline-2 has-[[data-slot=input-group-control]:focus-visible]:outline-offset-2 has-[[data-slot=input-group-control]:focus-visible]:outline-primary has-[[data-slot][aria-invalid=true]]:border-destructive has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
        className
      )}
      {...props}
    />
  )
}

const inputGroupAddonVariants = cva(
  "flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
  {
    variants: {
      align: {
        "inline-start":
          "order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
        "inline-end":
          "order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
        "block-start":
          "order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
        "block-end":
          "order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
      },
    },
    defaultVariants: {
      align: "inline-start",
    },
  }
)

function InputGroupAddon({
  className,
  align = "inline-start",
  ...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
  return (
    <div
      role="group"
      data-slot="input-group-addon"
      data-align={align}
      className={cn(inputGroupAddonVariants({ align }), className)}
      onClick={(e) => {
        if ((e.target as HTMLElement).closest("button")) {
          return
        }
        e.currentTarget.parentElement?.querySelector("input")?.focus()
      }}
      {...props}
    />
  )
}

const inputGroupButtonVariants = cva(
  "flex items-center gap-2 text-sm shadow-none",
  {
    variants: {
      size: {
        xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
        sm: "",
        "icon-xs":
          "size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
        "icon-sm": "size-8 p-0 has-[>svg]:p-0",
      },
    },
    defaultVariants: {
      size: "xs",
    },
  }
)

function InputGroupButton({
  className,
  type = "button",
  variant = "ghost",
  size = "xs",
  ...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
  VariantProps<typeof inputGroupButtonVariants>) {
  return (
    <Button
      type={type}
      data-size={size}
      variant={variant}
      className={cn(inputGroupButtonVariants({ size }), className)}
      {...props}
    />
  )
}

function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
  return (
    <span
      className={cn(
        "flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
        className
      )}
      {...props}
    />
  )
}

function InputGroupInput({
  className,
  ...props
}: React.ComponentProps<"input">) {
  return (
    <Input
      data-slot="input-group-control"
      className={cn(
        "flex-1 rounded-none border-0 bg-transparent shadow-none outline-none focus-visible:outline-none disabled:bg-transparent aria-invalid:outline-none dark:bg-transparent dark:disabled:bg-transparent",
        className
      )}
      {...props}
    />
  )
}

function InputGroupTextarea({
  className,
  ...props
}: React.ComponentProps<"textarea">) {
  return (
    <Textarea
      data-slot="input-group-control"
      className={cn(
        "flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none outline-none focus-visible:outline-none disabled:bg-transparent aria-invalid:outline-none dark:bg-transparent dark:disabled:bg-transparent",
        className
      )}
      {...props}
    />
  )
}

export {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupText,
  InputGroupInput,
  InputGroupTextarea,
}

Actualiza las rutas de importación según la configuración de tu proyecto.

Uso

import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupInput,
  InputGroupText,
  InputGroupTextarea,
} from "@/components/ui/input-group"
<InputGroup>
  <InputGroupInput placeholder="Search..." />
  <InputGroupAddon>
    <SearchIcon />
  </InputGroupAddon>
</InputGroup>

Composición

Usa la siguiente composición para construir un InputGroup:

InputGroup
├── InputGroupInput or InputGroupTextarea
├── InputGroupAddon
├── InputGroupButton
└── InputGroupText

Alineación

Usa la prop align de InputGroupAddon para posicionar el complemento respecto al campo de entrada.

inline-start

Usa align="inline-start" para colocar el complemento al principio del campo de entrada. Es el valor predeterminado.

import { SearchIcon } from "lucide-react"

import {

inline-end

Usa align="inline-end" para colocar el complemento al final del campo de entrada.

import { EyeOffIcon } from "lucide-react"

import {

block-start

Usa align="block-start" para colocar el complemento encima del campo de entrada.

import { CopyIcon, FileCodeIcon } from "lucide-react"

import {

block-end

Usa align="block-end" para colocar el complemento debajo del campo de entrada.

import {
  Field,
  FieldDescription,

Ejemplos

Icono

import {
  CheckIcon,
  CreditCardIcon,

Texto

import {
  InputGroup,
  InputGroupAddon,

Botón

"use client"

import * as React from "react"

Kbd

import { SearchIcon } from "lucide-react"

import {

Menú desplegable

"use client"

import { ChevronDownIcon, MoreHorizontal } from "lucide-react"

Spinner

import { LoaderIcon } from "lucide-react"

import {

Textarea

import {
  IconBrandJavascript,
  IconCopy,

Campo personalizado

Añade el atributo data-slot="input-group-control" a tu campo personalizado para gestionar automáticamente el estado de foco.

Aquí tienes un ejemplo de un textarea personalizado y redimensionable de una biblioteca de terceros.

"use client"

import TextareaAutosize from "react-textarea-autosize"

RTL

Para activar el soporte RTL en Neobrutalism, consulta la guía de configuración de RTL.

"use client"

import * as React from "react"

Accesibilidad

No existe un patrón de widget ARIA para los grupos de input: el contenedor renderiza role="group" para que la tecnología de asistencia asocie los complementos con el control, y el acceso por teclado es nativo — Tab alcanza el input y cualquier InputGroupButton en el orden del DOM, y por eso los complementos van después del input (posicionados visualmente con align). Dos cosas quedan de tu lado: el texto de un complemento como https:// es visual, no una etiqueta programática, así que el input sigue necesitando un <Label> o aria-label; y los botones de solo icono necesitan un aria-label — hacer clic en un complemento enfoca el input, pero eso es una comodidad de puntero, no una vía de teclado.

Referencia de API

InputGroup

El componente principal que envuelve los campos de entrada y los complementos.

PropTypeDefault
classNamestring
<InputGroup>
  <InputGroupInput />
  <InputGroupAddon />
</InputGroup>

InputGroupAddon

Muestra iconos, texto, botones u otro contenido junto a los campos de entrada.

PropTypeDefault
align"inline-start" | "inline-end" | "block-start" | "block-end""inline-start"
classNamestring
<InputGroupAddon align="inline-end">
  <SearchIcon />
</InputGroupAddon>

Para <InputGroupInput />, usa la alineación inline-start o inline-end. Para <InputGroupTextarea />, usa la alineación block-start o block-end.

El componente InputGroupAddon puede contener varios componentes InputGroupButton e iconos.

<InputGroupAddon>
  <InputGroupButton>Button</InputGroupButton>
  <InputGroupButton>Button</InputGroupButton>
</InputGroupAddon>

InputGroupButton

Muestra botones dentro de los grupos de entrada.

PropTypeDefault
size"xs" | "icon-xs" | "sm" | "icon-sm""xs"
variant"default" | "destructive" | "outline" | "secondary" | "ghost" | "link""ghost"
classNamestring
<InputGroupButton>Button</InputGroupButton>
<InputGroupButton size="icon-xs" aria-label="Copy">
  <CopyIcon />
</InputGroupButton>

InputGroupInput

Reemplazo de <Input /> al crear grupos de entrada. Este componente ya tiene aplicados los estilos del grupo de entrada y usa el data-slot="input-group-control" unificado para gestionar el estado de foco.

PropTypeDefault
classNamestring

El resto de las props se transmiten al componente <Input /> subyacente.

<InputGroup>
  <InputGroupInput placeholder="Enter text..." />
  <InputGroupAddon>
    <SearchIcon />
  </InputGroupAddon>
</InputGroup>

InputGroupTextarea

Reemplazo de <Textarea /> al crear grupos de entrada. Este componente ya tiene aplicados los estilos del grupo de textarea y usa el data-slot="input-group-control" unificado para gestionar el estado de foco.

PropTypeDefault
classNamestring

El resto de las props se transmiten al componente <Textarea /> subyacente.

<InputGroup>
  <InputGroupTextarea placeholder="Enter message..." />
  <InputGroupAddon align="block-end">
    <InputGroupButton>Send</InputGroupButton>
  </InputGroupAddon>
</InputGroup>