Vela UI Docsvenizia.ai

Input Group

Add addons, buttons, and helper content to inputs.

npx shadcn@latest add @velaui/input-group
npx shadcn@latest add @velaui/input-group

Examples

  • Start icon
  • End icon
  • Start text
  • Start text icon
  • End text
  • End text icon
  • Text add ons
  • End inline button
  • Characters limit
  • Icon and button
  • Block start
  • Block end
  • Block start block end
  • Chat input
  • Chat input abort
  • Chat messages
  • Chat input vietnamese
  • Chat input vietnamese error
  • Chat input vietnamese disabled

View code

Input Group · Start icon

Installation

pnpm dlx velaui add @velaui/input-group
pnpm dlx velaui add @velaui/input-group

Code

input-group-start-icon.tsx
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
} from "@/components/ui/input-group";
import { Label } from "@/components/ui/label";
import { Mail01 } from "@/components/icons/mail-01";
import { useId } from "react";

export default function Example() {
  const id = useId();

  return (
    <div className="w-full max-w-xs space-y-2">
      <Label htmlFor={id}>Input with start icon</Label>
      <InputGroup>
        <InputGroupAddon align={"inline-start"}>
          <Mail01 />
        </InputGroupAddon>
        <InputGroupInput id={id} placeholder="Email" />
      </InputGroup>
    </div>
  );
}

View code

Input Group · End icon

Installation

pnpm dlx velaui add @velaui/input-group
pnpm dlx velaui add @velaui/input-group

Code

input-group-end-icon.tsx
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
} from "@/components/ui/input-group";
import { Label } from "@/components/ui/label";
import { AlertCircle } from "@/components/icons/alert-circle";

export default function Example() {
  return (
    <div className="w-full max-w-xs space-y-2">
      <Label>Input with end icon</Label>
      <InputGroup>
        <InputGroupInput placeholder="Card number" />
        <InputGroupAddon align={"inline-end"}>
          <AlertCircle />
        </InputGroupAddon>
      </InputGroup>
    </div>
  );
}

View code

Input Group · Start text

Installation

pnpm dlx velaui add @velaui/input-group
pnpm dlx velaui add @velaui/input-group

Code

input-group-start-text.tsx
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
  InputGroupText,
} from "@/components/ui/input-group";
import { Label } from "@/components/ui/label";
import { useId } from "react";

export default function Example() {
  const id = useId();

  return (
    <div className="w-full max-w-xs space-y-2">
      <Label htmlFor={id}>Input with start text</Label>
      <InputGroup>
        <InputGroupAddon align={"inline-start"}>
          <InputGroupText>https://</InputGroupText>
        </InputGroupAddon>
        <InputGroupInput id={id} placeholder="veni.dev.venizia.ai" />
      </InputGroup>
    </div>
  );
}

View code

Input Group · Start text icon

Installation

pnpm dlx velaui add @velaui/input-group
pnpm dlx velaui add @velaui/input-group

Code

input-group-start-text-icon.tsx
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
  InputGroupText,
} from "@/components/ui/input-group";
import { ChevronDown } from "@/components/icons/chevron-down";

export default function Example() {
  return (
    <InputGroup>
      <InputGroupAddon align={"inline-start"}>
        <InputGroupText>Prefix</InputGroupText>
        <ChevronDown />
      </InputGroupAddon>
      <InputGroupInput placeholder="Type here" />
    </InputGroup>
  );
}

View code

Input Group · End text

Installation

pnpm dlx velaui add @velaui/input-group
pnpm dlx velaui add @velaui/input-group

Code

input-group-end-text.tsx
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
  InputGroupText,
} from "@/components/ui/input-group";
import { Label } from "@/components/ui/label";
import { useId } from "react";

export default function Example() {
  const id = useId();

  return (
    <div className="w-full max-w-xs space-y-2">
      <Label htmlFor={id}>Input with end text</Label>
      <InputGroup>
        <InputGroupInput id={id} placeholder="100000" />
        <InputGroupAddon align={"inline-end"}>
          <InputGroupText>VND</InputGroupText>
        </InputGroupAddon>
      </InputGroup>
    </div>
  );
}

View code

Input Group · End text icon

Installation

pnpm dlx velaui add @velaui/input-group
pnpm dlx velaui add @velaui/input-group

Code

input-group-end-text-icon.tsx
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
  InputGroupText,
} from "@/components/ui/input-group";
import { ChevronDown } from "@/components/icons/chevron-down";

export default function Example() {
  return (
    <InputGroup>
      <InputGroupInput placeholder="Type here" />
      <InputGroupAddon align={"inline-end"}>
        <InputGroupText>Prefix</InputGroupText>
        <ChevronDown />
      </InputGroupAddon>
    </InputGroup>
  );
}

View code

Input Group · Text add ons

Installation

pnpm dlx velaui add @velaui/input-group
pnpm dlx velaui add @velaui/input-group

Code

input-group-text-add-ons.tsx
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
  InputGroupText,
} from "@/components/ui/input-group";
import { Label } from "@/components/ui/label";
import { useId } from "react";

export default function Example() {
  const id = useId();

  return (
    <div className="w-full max-w-xs space-y-2">
      <Label htmlFor={id}>Input with text add-ons</Label>
      <InputGroup>
        <InputGroupAddon align={"inline-start"}>
          <InputGroupText>https://</InputGroupText>
        </InputGroupAddon>
        <InputGroupAddon align={"inline-end"}>
          <InputGroupText>.com</InputGroupText>
        </InputGroupAddon>
        <InputGroupInput id={id} placeholder="veni.dev.venizia.ai" />
      </InputGroup>
    </div>
  );
}

View code

Input Group · End inline button

Installation

pnpm dlx velaui add @velaui/input-group
pnpm dlx velaui add @velaui/input-group

Code

input-group-end-inline-button.tsx
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupInput,
} from "@/components/ui/input-group";
import { Label } from "@/components/ui/label";
import { Send02 } from "@/components/icons/send-02";
import { useId } from "react";

export default function Example() {
  const id = useId();

  return (
    <div className="w-full max-w-xs space-y-2">
      <Label htmlFor={id}>Input with end inline button</Label>
      <InputGroup>
        <InputGroupInput id={id} placeholder="Email address" />
        <InputGroupAddon align={"inline-end"}>
          <InputGroupButton>
            <Send02 />
          </InputGroupButton>
        </InputGroupAddon>
      </InputGroup>
    </div>
  );
}

View code

Input Group · Characters limit

Installation

pnpm dlx velaui add @velaui/input-group
pnpm dlx velaui add @velaui/input-group

Code

input-group-characters-limit.tsx
import { useId, useState, type ChangeEvent } from "react";

import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
} from "@/components/ui/input-group";
import { Label } from "@/components/ui/label";

const maxLength = 50;
const initialValue = "";

export default function InputCharacterLimitDemo() {
  const [value, setValue] = useState(initialValue);

  const id = useId();

  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
    if (e.target.value.length <= maxLength) {
      setValue(e.target.value);
    }
  };

  return (
    <div className="w-full max-w-xs space-y-2">
      <Label htmlFor={id}>Input with character limit</Label>
      <InputGroup>
        <InputGroupInput
          id={id}
          type="text"
          placeholder="Username"
          value={value}
          maxLength={maxLength}
          onChange={handleChange}
        />
        <InputGroupAddon align="inline-end">
          <span className="text-muted-foreground pointer-events-none text-xs tabular-nums">
            {value.length}/{maxLength}
          </span>
        </InputGroupAddon>
      </InputGroup>
    </div>
  );
}

View code

Input Group · Icon and button

Installation

pnpm dlx velaui add @velaui/input-group
pnpm dlx velaui add @velaui/input-group

Code

input-group-icon-and-button.tsx
import { useId } from "react";
import { Button } from "@/components/ui/button";
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
} from "@/components/ui/input-group";
import { Label } from "@/components/ui/label";
import { Microphone01 } from "@/components/icons/microphone-01";
import { Search01 } from "@/components/icons/search-01";

export default function InputSearchIconDemo() {
  const id = useId();

  return (
    <div className="w-full max-w-xs space-y-2">
      <Label htmlFor={id}>Search input with icon and button</Label>
      <InputGroup>
        <InputGroupAddon>
          <Search01 className="size-4" />
          <span className="sr-only">Search</span>
        </InputGroupAddon>
        <InputGroupInput
          id={id}
          type="search"
          placeholder="Search..."
          className="[&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none [&::-webkit-search-results-button]:appearance-none [&::-webkit-search-results-decoration]:appearance-none"
        />
        <InputGroupAddon align="inline-end">
          <Button
            variant="ghost"
            size="icon-sm"
            className="text-muted-foreground hover:bg-transparent"
          >
            <Microphone01 />
            <span className="sr-only">Press to speak</span>
          </Button>
        </InputGroupAddon>
      </InputGroup>
    </div>
  );
}

View code

Input Group · Block start

Installation

pnpm dlx velaui add @velaui/input-group
pnpm dlx velaui add @velaui/input-group

Code

input-group-block-start.tsx
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupInput,
  InputGroupText,
  InputGroupTextarea,
} from "@/components/ui/input-group";
import { Label } from "@/components/ui/label";
import { CopyLeft } from "@/components/icons/copy-left";
import { FileCode } from "@/components/icons/file-code";

export default function Example() {
  return (
    <div className="space-y-4">
      <div className="w-full max-w-xs space-y-2">
        <Label htmlFor="input-block-start">Input with block start</Label>
        <InputGroup>
          <InputGroupInput
            id="input-block-start"
            placeholder="Enter your full name"
          />
          <InputGroupAddon align={"block-start"}>
            <InputGroupText>Full name</InputGroupText>
          </InputGroupAddon>
        </InputGroup>
        <p className="text-muted-foreground text-xs">
          Header positioned above the input.
        </p>
      </div>
      <div className="w-full max-w-xs space-y-2">
        <Label htmlFor="textarea-block-start">Textarea with block start</Label>
        <InputGroup>
          <InputGroupTextarea
            id="textarea-block-start"
            placeholder="console.log('Hello World!');"
          />
          <InputGroupAddon align={"block-start"}>
            <FileCode />
            <InputGroupText>Script.js</InputGroupText>
            <InputGroupButton className="ml-auto">
              <CopyLeft />
            </InputGroupButton>
          </InputGroupAddon>
        </InputGroup>
        <p className="text-muted-foreground text-xs">
          Header positioned above the textarea.
        </p>
      </div>
    </div>
  );
}

View code

Input Group · Block end

Installation

pnpm dlx velaui add @velaui/input-group
pnpm dlx velaui add @velaui/input-group

Code

input-group-block-end.tsx
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupInput,
  InputGroupText,
  InputGroupTextarea,
} from "@/components/ui/input-group";
import { Label } from "@/components/ui/label";

export default function InputGroupBlockEnd() {
  return (
    <div className="space-y-4">
      <div className="w-full max-w-xs space-y-2">
        <Label htmlFor="block-end-input">Input with block end</Label>
        <InputGroup className="h-auto">
          <InputGroupInput id="block-end-input" placeholder="Enter amount" />
          <InputGroupAddon align="block-end">
            <InputGroupText>USD</InputGroupText>
          </InputGroupAddon>
        </InputGroup>
        <p className="text-muted-foreground text-xs">
          Footer positioned below the input.
        </p>
      </div>
      <div className="w-full max-w-xs space-y-2">
        <Label htmlFor="block-end-textarea">Textarea with block end</Label>
        <InputGroup>
          <InputGroupTextarea
            id="block-end-textarea"
            placeholder="Write a comment..."
          />
          <InputGroupAddon align="block-end">
            <InputGroupText>0/280</InputGroupText>
            <InputGroupButton variant="primary" size="sm" className="ml-auto">
              Post
            </InputGroupButton>
          </InputGroupAddon>
        </InputGroup>
        <p className="text-muted-foreground text-xs">
          Footer positioned below the textarea.
        </p>
      </div>
    </div>
  );
}

View code

Input Group · Block start block end

Installation

pnpm dlx velaui add @velaui/input-group
pnpm dlx velaui add @velaui/input-group

Code

input-group-block-start-block-end.tsx
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupText,
  InputGroupTextarea,
} from "@/components/ui/input-group";
import { Label } from "@/components/ui/label";
import { ArrowCurveLeftDown } from "@/components/icons/arrow-curve-left-down";
import { ArrowRefresh01 } from "@/components/icons/arrow-refresh-01";
import { CopyLeft } from "@/components/icons/copy-left";
import { FileCode } from "@/components/icons/file-code";
import { useId } from "react";

export default function InputGroupTextareaExample() {
  const id = useId();
  return (
    <div className="grid w-full max-w-md gap-4">
      <Label htmlFor={id}>Textarea with block-start and block-end</Label>
      <InputGroup>
        <InputGroupTextarea
          id={id}
          placeholder="console.log('Hello, world!');"
          className="min-h-40"
        />
        <InputGroupAddon align="block-end" className="border-t">
          <InputGroupText>Line 1, Column 1</InputGroupText>
          <InputGroupButton size="sm" className="ml-auto" variant="primary">
            Run <ArrowCurveLeftDown />
          </InputGroupButton>
        </InputGroupAddon>
        <InputGroupAddon align="block-start" className="border-b">
          <InputGroupText className="font-mono font-medium">
            <FileCode />
            script.js
          </InputGroupText>
          <InputGroupButton className="ml-auto" size="icon-xs">
            <ArrowRefresh01 />
          </InputGroupButton>
          <InputGroupButton variant="ghost" size="icon-xs">
            <CopyLeft />
          </InputGroupButton>
        </InputGroupAddon>
      </InputGroup>
    </div>
  );
}

View code

Input Group · Chat input

Installation

pnpm dlx velaui add @velaui/input-group
pnpm dlx velaui add @velaui/input-group

Code

input-group-chat-input.tsx
import { useEffect, useId, useRef, useState } from "react";

import { Button } from "@/components/ui/button";
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupTextarea,
} from "@/components/ui/input-group";
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/components/ui/tooltip";
import { Label } from "@/components/ui/label";
import { ClockBackward } from "@/components/icons/clock-backward";
import { Close02 } from "@/components/icons/close-02";
import { Loader01 } from "@/components/icons/loader-01";
import { Paperclip02 } from "@/components/icons/paperclip-02";
import { Plug01 } from "@/components/icons/plug-01";
import { Send01 } from "@/components/icons/send-01";

/**
 * The run lifecycle behind the composer, mirroring the builder's job states:
 * the send request is in flight, the run is generating, the stop request is in
 * flight, and the run was stopped — "Cancelled", the way BuildPage's
 * `POST /builder/jobs/:id/cancel` settles a stop (#425).
 */
type RunState = "idle" | "sending" | "running" | "stopping" | "cancelled";

export default function ChatInputExample() {
  const id = useId();
  const [value, setValue] = useState("");
  const [run, setRun] = useState<RunState>("idle");
  const timers = useRef<number[]>([]);

  useEffect(() => () => timers.current.forEach((t) => window.clearTimeout(t)), []);

  const active = run === "sending" || run === "running" || run === "stopping";

  const send = () => {
    // Enter sends; Shift+Enter inserts a newline. A running or stopping run
    // takes no new prompt, the way the rail's `blocked` gate works.
    if (!value.trim() || active) return;
    setValue("");
    setRun("sending");
    timers.current.push(window.setTimeout(() => setRun("running"), 1000));
  };

  // The builder's stop (#425): the × is disabled while the cancel request is
  // in flight (`pending === "stop"`, `disabled={busy}`), then the run flips to
  // cancelled — the conversation records it and the composer comes back for
  // the next message.
  const stop = () => {
    if (run !== "running") return;
    setRun("stopping");
    timers.current.push(window.setTimeout(() => setRun("cancelled"), 600));
  };

  return (
    <div className="grid w-full max-w-md gap-4">
      <Label htmlFor={id}>Chat input</Label>
      <TooltipProvider>
        <InputGroup>
          <InputGroupTextarea
            id={id}
            rows={3}
            placeholder="Ask anything…"
            className="max-h-60 overflow-y-auto"
            value={value}
            onChange={(e) => setValue(e.target.value)}
            onKeyDown={(e) => {
              if (e.key === "Enter" && !e.shiftKey) {
                e.preventDefault();
                send();
              }
            }}
          />
          <InputGroupAddon align="block-end" className="border-t">
            <Tooltip>
              <TooltipTrigger asChild>
                <InputGroupButton
                  variant="outline"
                  size="icon-sm"
                  aria-label="Version history"
                >
                  <ClockBackward />
                </InputGroupButton>
              </TooltipTrigger>
              <TooltipContent>Version history</TooltipContent>
            </Tooltip>
            <Tooltip>
              <TooltipTrigger asChild>
                <InputGroupButton
                  variant="ghost"
                  size="icon-sm"
                  aria-label="Attach files"
                  className="text-icon-note hover:text-icon-strong"
                >
                  <Paperclip02 />
                </InputGroupButton>
              </TooltipTrigger>
              <TooltipContent>Attach files</TooltipContent>
            </Tooltip>
            <Tooltip>
              <TooltipTrigger asChild>
                <InputGroupButton
                  variant="ghost"
                  size="icon-sm"
                  aria-label="Connectors"
                  className="text-icon-note hover:text-icon-strong"
                >
                  <Plug01 />
                </InputGroupButton>
              </TooltipTrigger>
              <TooltipContent>Connectors</TooltipContent>
            </Tooltip>
            {run === "running" || run === "stopping" ? (
              <Tooltip>
                <TooltipTrigger asChild>
                  <Button
                    size="icon-sm"
                    aria-label="Stop build"
                    className="ml-auto"
                    onClick={stop}
                    disabled={run === "stopping"}
                  >
                    <Close02 className="size-4" strokeWidth={1.75} />
                  </Button>
                </TooltipTrigger>
                <TooltipContent>Stop build</TooltipContent>
              </Tooltip>
            ) : (
              <InputGroupButton
                variant="primary"
                size="icon-sm"
                aria-label="Send"
                className="ml-auto"
                onClick={send}
                disabled={active || !value.trim()}
              >
                {run === "sending" ? (
                  <Loader01 className="animate-spin" />
                ) : (
                  <Send01 />
                )}
              </InputGroupButton>
            )}
          </InputGroupAddon>
        </InputGroup>
      </TooltipProvider>
      {/* The conversation's account of the run, the way the rail reads a job:
       *  building, then the cancelled notice, then nothing. */}
      {run !== "idle" && (
        <p role="status" className="text-xs text-text-muted">
          {run === "sending" && "Sending…"}
          {run === "running" && "Building…"}
          {run === "stopping" && "Stopping…"}
          {run === "cancelled" && "Cancelled"}
        </p>
      )}
    </div>
  );
}
import { useEffect, useId, useRef, useState } from "react";

import { Button } from "@/components/ui/button";
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupTextarea,
} from "@/components/ui/input-group";
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/components/ui/tooltip";
import { Label } from "@/components/ui/label";
import { ClockBackward } from "@/components/icons/clock-backward";
import { Close02 } from "@/components/icons/close-02";
import { Loader01 } from "@/components/icons/loader-01";
import { Paperclip02 } from "@/components/icons/paperclip-02";
import { Plug01 } from "@/components/icons/plug-01";
import { Send01 } from "@/components/icons/send-01";

/**
 * The run lifecycle behind the composer, mirroring the builder's job states:
 * the send request is in flight, the run is generating, the stop request is in
 * flight, and the run was stopped — "Cancelled", the way BuildPage's
 * `POST /builder/jobs/:id/cancel` settles a stop (#425).
 */
type RunState = "idle" | "sending" | "running" | "stopping" | "cancelled";

export default function ChatInputExample() {
  const id = useId();
  const [value, setValue] = useState("");
  const [run, setRun] = useState<RunState>("idle");
  const timers = useRef<number[]>([]);

  useEffect(() => () => timers.current.forEach((t) => window.clearTimeout(t)), []);

  const active = run === "sending" || run === "running" || run === "stopping";

  const send = () => {
    // Enter sends; Shift+Enter inserts a newline. A running or stopping run
    // takes no new prompt, the way the rail's `blocked` gate works.
    if (!value.trim() || active) return;
    setValue("");
    setRun("sending");
    timers.current.push(window.setTimeout(() => setRun("running"), 1000));
  };

  // The builder's stop (#425): the × is disabled while the cancel request is
  // in flight (`pending === "stop"`, `disabled={busy}`), then the run flips to
  // cancelled — the conversation records it and the composer comes back for
  // the next message.
  const stop = () => {
    if (run !== "running") return;
    setRun("stopping");
    timers.current.push(window.setTimeout(() => setRun("cancelled"), 600));
  };

  return (
    <div className="grid w-full max-w-md gap-4">
      <Label htmlFor={id}>Chat input</Label>
      <TooltipProvider>
        <InputGroup>
          <InputGroupTextarea
            id={id}
            rows={3}
            placeholder="Ask anything…"
            className="max-h-60 overflow-y-auto"
            value={value}
            onChange={(e) => setValue(e.target.value)}
            onKeyDown={(e) => {
              if (e.key === "Enter" && !e.shiftKey) {
                e.preventDefault();
                send();
              }
            }}
          />
          <InputGroupAddon align="block-end" className="border-t">
            <Tooltip>
              <TooltipTrigger
                render={
                  <InputGroupButton
                    variant="outline"
                    size="icon-sm"
                    aria-label="Version history"
                  />
                }
              >
                <ClockBackward />
              </TooltipTrigger>
              <TooltipContent>Version history</TooltipContent>
            </Tooltip>
            <Tooltip>
              <TooltipTrigger
                render={
                  <InputGroupButton
                    variant="ghost"
                    size="icon-sm"
                    aria-label="Attach files"
                    className="text-icon-note hover:text-icon-strong"
                  />
                }
              >
                <Paperclip02 />
              </TooltipTrigger>
              <TooltipContent>Attach files</TooltipContent>
            </Tooltip>
            <Tooltip>
              <TooltipTrigger
                render={
                  <InputGroupButton
                    variant="ghost"
                    size="icon-sm"
                    aria-label="Connectors"
                    className="text-icon-note hover:text-icon-strong"
                  />
                }
              >
                <Plug01 />
              </TooltipTrigger>
              <TooltipContent>Connectors</TooltipContent>
            </Tooltip>
            {run === "running" || run === "stopping" ? (
              <Tooltip>
                <TooltipTrigger
                  render={
                    <Button
                      size="icon-sm"
                      aria-label="Stop build"
                      className="ml-auto"
                      onClick={stop}
                      disabled={run === "stopping"}
                    />
                  }
                >
                  <Close02 className="size-4" strokeWidth={1.75} />
                </TooltipTrigger>
                <TooltipContent>Stop build</TooltipContent>
              </Tooltip>
            ) : (
              <InputGroupButton
                variant="primary"
                size="icon-sm"
                aria-label="Send"
                className="ml-auto"
                onClick={send}
                disabled={active || !value.trim()}
              >
                {run === "sending" ? (
                  <Loader01 className="animate-spin" />
                ) : (
                  <Send01 />
                )}
              </InputGroupButton>
            )}
          </InputGroupAddon>
        </InputGroup>
      </TooltipProvider>
      {/* The conversation's account of the run, the way the rail reads a job:
       *  building, then the cancelled notice, then nothing. */}
      {run !== "idle" && (
        <p role="status" className="text-xs text-text-muted">
          {run === "sending" && "Sending…"}
          {run === "running" && "Building…"}
          {run === "stopping" && "Stopping…"}
          {run === "cancelled" && "Cancelled"}
        </p>
      )}
    </div>
  );
}

View code

Input Group · Chat input abort

Installation

pnpm dlx velaui add @velaui/input-group
pnpm dlx velaui add @velaui/input-group

Code

input-group-chat-input-abort.tsx
import { useId, useState } from "react";

import { Button } from "@/components/ui/button";
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupTextarea,
} from "@/components/ui/input-group";
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/components/ui/tooltip";
import { Label } from "@/components/ui/label";
import { ArrowUp } from "@/components/icons/arrow-up";
import { Paperclip02 } from "@/components/icons/paperclip-02";
import { Plug01 } from "@/components/icons/plug-01";
import { Stop01 } from "@/components/icons/stop-01";

/**
 * The form-editor chat composer from veni-os (`chat-panel.tsx`): the stop
 * button never replaces send. While a turn streams, a round outline Stop
 * (`Stop01`, `onAbort`) appears beside a still-enabled send — a new prompt
 * steers the running turn — and stopping never blocks or resets the composer,
 * unlike veni-builder's composer, where × swaps send out and cancels the run.
 */
export default function ChatInputAbortExample() {
  const id = useId();
  const [draft, setDraft] = useState("");
  const [streaming, setStreaming] = useState(false);
  const [stopped, setStopped] = useState(false);

  // veni-os `submit()`: every prompt sends, streaming or not — while a turn
  // runs, the composer is the steering wheel, not a blocked gate.
  const send = () => {
    if (!draft.trim()) return;
    setDraft("");
    setStopped(false);
    setStreaming(true);
  };

  // veni-os `onAbort()`: end the running turn; the composer never left.
  const abort = () => {
    setStreaming(false);
    setStopped(true);
  };

  return (
    <div className="grid w-full max-w-md gap-4">
      <Label htmlFor={id}>Chat input with abort</Label>
      <TooltipProvider delayDuration={200}>
        <InputGroup>
          <InputGroupTextarea
            id={id}
            rows={2}
            placeholder={streaming ? "Steer the running turn…" : "Ask your assistant…"}
            value={draft}
            onChange={(e) => setDraft(e.target.value)}
            onKeyDown={(e) => {
              // Enter sends, Shift+Enter breaks the line; an IME composition
              // (Vietnamese Telex) commits on Enter and must not send.
              if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
                e.preventDefault();
                send();
              }
            }}
          />
          <InputGroupAddon align="block-end" className="justify-between border-t">
            <div className="flex items-center gap-1">
              <Tooltip>
                <TooltipTrigger asChild>
                  <InputGroupButton
                    variant="ghost"
                    size="icon-sm"
                    aria-label="Attach images"
                    className="text-icon-note hover:text-icon-strong"
                  >
                    <Paperclip02 />
                  </InputGroupButton>
                </TooltipTrigger>
                <TooltipContent>Attach up to 4 images, 5 MB each</TooltipContent>
              </Tooltip>
              <Tooltip>
                <TooltipTrigger asChild>
                  {/* aria-disabled, not disabled: a natively disabled button
                   * swallows the hover that explains why. */}
                  <InputGroupButton
                    variant="ghost"
                    size="icon-sm"
                    aria-label="MCP connections"
                    aria-disabled
                    className="text-icon-note opacity-50 hover:text-icon-strong"
                  >
                    <Plug01 />
                  </InputGroupButton>
                </TooltipTrigger>
                <TooltipContent>MCP connections — coming soon</TooltipContent>
              </Tooltip>
            </div>
            <div className="flex items-center gap-2">
              {streaming && (
                <Button
                  variant="outline"
                  size="icon-sm"
                  className="rounded-full"
                  aria-label="Stop the running turn"
                  onClick={abort}
                >
                  <Stop01 className="size-4" strokeWidth={1.75} />
                </Button>
              )}
              <Button
                variant="primary"
                size="icon-sm"
                className="rounded-full"
                disabled={!draft.trim()}
                aria-label="Send"
                onClick={send}
              >
                <ArrowUp className="size-4" strokeWidth={1.75} />
              </Button>
            </div>
          </InputGroupAddon>
        </InputGroup>
      </TooltipProvider>
      {(streaming || stopped) && (
        <p role="status" className="text-xs text-text-muted">
          {streaming ? "Working…" : "Stopped"}
        </p>
      )}
    </div>
  );
}
import { useId, useState } from "react";

import { Button } from "@/components/ui/button";
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupTextarea,
} from "@/components/ui/input-group";
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/components/ui/tooltip";
import { Label } from "@/components/ui/label";
import { ArrowUp } from "@/components/icons/arrow-up";
import { Paperclip02 } from "@/components/icons/paperclip-02";
import { Plug01 } from "@/components/icons/plug-01";
import { Stop01 } from "@/components/icons/stop-01";

/**
 * The form-editor chat composer from veni-os (`chat-panel.tsx`): the stop
 * button never replaces send. While a turn streams, a round outline Stop
 * (`Stop01`, `onAbort`) appears beside a still-enabled send — a new prompt
 * steers the running turn — and stopping never blocks or resets the composer,
 * unlike veni-builder's composer, where × swaps send out and cancels the run.
 */
export default function ChatInputAbortExample() {
  const id = useId();
  const [draft, setDraft] = useState("");
  const [streaming, setStreaming] = useState(false);
  const [stopped, setStopped] = useState(false);

  // veni-os `submit()`: every prompt sends, streaming or not — while a turn
  // runs, the composer is the steering wheel, not a blocked gate.
  const send = () => {
    if (!draft.trim()) return;
    setDraft("");
    setStopped(false);
    setStreaming(true);
  };

  // veni-os `onAbort()`: end the running turn; the composer never left.
  const abort = () => {
    setStreaming(false);
    setStopped(true);
  };

  return (
    <div className="grid w-full max-w-md gap-4">
      <Label htmlFor={id}>Chat input with abort</Label>
      <TooltipProvider>
        <InputGroup>
          <InputGroupTextarea
            id={id}
            rows={2}
            placeholder={streaming ? "Steer the running turn…" : "Ask your assistant…"}
            value={draft}
            onChange={(e) => setDraft(e.target.value)}
            onKeyDown={(e) => {
              // Enter sends, Shift+Enter breaks the line; an IME composition
              // (Vietnamese Telex) commits on Enter and must not send.
              if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
                e.preventDefault();
                send();
              }
            }}
          />
          <InputGroupAddon align="block-end" className="justify-between border-t">
            <div className="flex items-center gap-1">
              <Tooltip>
                <TooltipTrigger
                  render={
                    <InputGroupButton
                      variant="ghost"
                      size="icon-sm"
                      aria-label="Attach images"
                      className="text-icon-note hover:text-icon-strong"
                    />
                  }
                >
                  <Paperclip02 />
                </TooltipTrigger>
                <TooltipContent>Attach up to 4 images, 5 MB each</TooltipContent>
              </Tooltip>
              <Tooltip>
                <TooltipTrigger
                  render={
                    <InputGroupButton
                      variant="ghost"
                      size="icon-sm"
                      aria-label="MCP connections"
                      aria-disabled
                      className="text-icon-note opacity-50 hover:text-icon-strong"
                    />
                  }
                >
                  <Plug01 />
                </TooltipTrigger>
                <TooltipContent>MCP connections — coming soon</TooltipContent>
              </Tooltip>
            </div>
            <div className="flex items-center gap-2">
              {streaming && (
                <Button
                  variant="outline"
                  size="icon-sm"
                  className="rounded-full"
                  aria-label="Stop the running turn"
                  onClick={abort}
                >
                  <Stop01 className="size-4" strokeWidth={1.75} />
                </Button>
              )}
              <Button
                variant="primary"
                size="icon-sm"
                className="rounded-full"
                disabled={!draft.trim()}
                aria-label="Send"
                onClick={send}
              >
                <ArrowUp className="size-4" strokeWidth={1.75} />
              </Button>
            </div>
          </InputGroupAddon>
        </InputGroup>
      </TooltipProvider>
      {(streaming || stopped) && (
        <p role="status" className="text-xs text-text-muted">
          {streaming ? "Working…" : "Stopped"}
        </p>
      )}
    </div>
  );
}

View code

Input Group · Chat messages

Installation

pnpm dlx velaui add @velaui/input-group
pnpm dlx velaui add @velaui/input-group

Code

input-group-chat-messages.tsx
import { useEffect, useRef, useState } from "react";

import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupTextarea,
} from "@/components/ui/input-group";
import { ArrowUp } from "@/components/icons/arrow-up";
import { Check01 } from "@/components/icons/check-01";
import { ChevronRight } from "@/components/icons/chevron-right";
import { Loader01 } from "@/components/icons/loader-01";

/**
 * The build rail's conversation from veni-builder (`ChatRail.tsx`) as a live
 * chat area, demonstrating every kind of row the rail shows. What the person
 * sends is a right-aligned bubble hugging its text, primary-tinted with a
 * squared top-right corner and their name above (#661); what the agent says
 * is plain prose; the plan's questions are a bordered card (#300). The
 * replies differ by how they were sent:
 *
 *  - answers — the composer while the plan asks its questions, one line per
 *    answer (`/answer`, the `answers` array joined);
 *  - a decisions pick — "Apply choices" on the plan's decisions, sent as a
 *    formatted change (`/revise`, `choicesMessage`, #1361);
 *  - a change asked of the plan — the composer while the plan waits for
 *    approval, free text (`/revise`, the `revision`);
 *  - a narrowing — "Narrow it" on a large plan (`/narrow`), which the rail
 *    shows as a status row, not a person bubble (`{ narrowed }`).
 *
 * The scratch work folds away the same way: a build-log row ("Build log · N
 * steps", `DetailsRow`) that expands into its tool calls — a check per done
 * step, the one error line worth reading — with the agent's narration
 * interleaved (#784). Typing below runs the flow: the send lands as a person
 * turn, Appia answers after a thinking beat. The area resizes horizontally
 * from its right edge, by drag or arrow keys.
 */
interface ChatMessage {
  role: "person" | "agent" | "system";
  text?: string;
  author?: string;
  /** The plan's questions (#300): an agent turn that is a card, and the one
   *  the area's next reply answers. */
  questions?: string[];
  /** The folded build log (`DetailsRow`): tool calls with the agent's
   *  narration interleaved, collapsed until asked for. */
  log?: LogStep[];
}

interface LogStep {
  /** The command's first line, the way the rail truncates it. */
  command?: string;
  /** A step that finished with output; the text shows only on error. */
  result?: string;
  error?: boolean;
  /** What the agent said between calls (#784). */
  note?: string;
}

const QUESTIONS = [
  "Who approves a leave request — the requester's manager or HR?",
  "Should balances roll over at the year's end?",
];

const SEEDED: ChatMessage[] = [
  { role: "person", author: "Hiếu Võ", text: "Build leave request management with an approval flow." },
  { role: "agent", text: "I've planned the module — two things before I build." },
  { role: "agent", questions: QUESTIONS },
  // Answers: one line per answer, the way `answers` arrives from /answer.
  { role: "person", author: "Hiếu Võ", text: "The requester's manager approves.\nBalances don't roll over at the year's end." },
  { role: "agent", text: "Planned. One default to check: leave books by full days." },
  // A decisions pick: the formatted change /revise carries (#1361).
  { role: "person", author: "Hiếu Võ", text: "I am changing these choices in the plan:\n- Leave unit: half days (instead of full days only)" },
  { role: "agent", text: "Replanned with half days. It's a large first version — you can build part of it now." },
  // A change asked of the plan: the free-text /revise while it waits.
  { role: "person", author: "Hiếu Võ", text: "Cut the audit log for now — approvals only." },
  // A narrowing: /narrow reads as a status row, not a person bubble.
  { role: "system", text: "Narrowed the plan — building only Leave requests and Approvals; the rest in a later change." },
  { role: "agent", text: "Building those now." },
  {
    role: "agent",
    text: "The stack matches the plan — running the checks before hand-off.",
    log: [
      { command: "Read src/approvals/LeaveRequest.ts" },
      { command: "Glob src/approvals/**" },
      { command: "Bash npm run lint", result: "2 errors in src/balances/BalanceView.tsx", error: true },
      { note: "Lint flags unused props in the balance view — fixing those, then lint again." },
      { command: "Bash npm run lint", result: "No problems" },
      { command: "Bash npm run build", result: "built in 41s" },
    ],
  },
  { role: "agent", text: "Checks green — the approval flow is ready for review." },
];

/** What Appia answers with, one per turn the area runs. */
const REPLIES = [
  "Noted — routing approvals to the requester's manager and resetting balances each January.",
  "Adding the balance view next: accrued, taken and remaining per person, refreshed on approval.",
  "The approval queue is live for managers. Anything else before I hand this over?",
];

const MIN_WIDTH = 320;
const MAX_WIDTH = 640;
const THINKING_MS = 1100;

/** The folded build log (`DetailsRow`): collapsed until asked for, the
 *  chevron turning with it. A done step carries a check; its output text is
 *  only worth reading when the step failed. */
function BuildLog({ steps }: { steps: LogStep[] }) {
  const [open, setOpen] = useState(false);
  return (
    <div className="px-1">
      <button
        type="button"
        className="flex items-center gap-1 text-xs text-text-muted hover:text-text-strong"
        aria-expanded={open}
        onClick={() => setOpen(o => !o)}
      >
        <ChevronRight className={`size-3.5 transition-transform ${open ? "rotate-90" : ""}`} />
        {`Build log · ${steps.length} steps`}
      </button>
      {open && (
        <ol className="mt-1 space-y-1 border-l border-border-default pl-2 text-xs text-text-muted">
          {steps.map((step, index) =>
            step.note ? (
              <li key={index} className="whitespace-pre-wrap">
                {step.note}
              </li>
            ) : (
              <li key={index} className="flex items-start gap-2 py-0.5">
                {step.error ? (
                  <Check01 className="mt-0.5 size-3.5 shrink-0 text-negative-middle" strokeWidth={2.5} />
                ) : (
                  <Check01 className="mt-0.5 size-3.5 shrink-0 text-success-strong" strokeWidth={2.5} />
                )}
                <span className="min-w-0 flex-1">
                  <span className="block truncate text-xs font-medium">{step.command}</span>
                  {step.error && step.result && (
                    <span className="block truncate text-xs text-negative-middle">{step.result}</span>
                  )}
                </span>
              </li>
            ),
          )}
        </ol>
      )}
    </div>
  );
}

export default function ChatMessagesExample() {
  const [messages, setMessages] = useState<ChatMessage[]>(SEEDED);
  const [draft, setDraft] = useState("");
  const [thinking, setThinking] = useState(false);
  const [replyIndex, setReplyIndex] = useState(0);
  const [width, setWidth] = useState(440);
  const [dragging, setDragging] = useState(false);
  const dragStart = useRef<{ x: number; width: number } | null>(null);
  const logRef = useRef<HTMLDivElement>(null);

  // The thinking beat is the flow: the send lands, the agent is seen working,
  // then its turn arrives. The timer cancels if the example unmounts mid-beat.
  useEffect(() => {
    if (!thinking) return;
    const timer = setTimeout(() => {
      setThinking(false);
      setMessages(m => [...m, { role: "agent", text: REPLIES[replyIndex % REPLIES.length] }]);
      setReplyIndex(i => i + 1);
    }, THINKING_MS);
    return () => clearTimeout(timer);
  }, [thinking, replyIndex]);

  // New turns pin to the bottom, like the rail the example is drawn from.
  useEffect(() => {
    const log = logRef.current;
    if (log) log.scrollTop = log.scrollHeight;
  }, [messages, thinking]);

  useEffect(() => {
    if (!dragging) return;
    const move = (e: PointerEvent) => {
      if (!dragStart.current) return;
      // The handle is the right edge: dragging right widens the area.
      const next = dragStart.current.width + (e.clientX - dragStart.current.x);
      setWidth(Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, next)));
    };
    const up = () => {
      dragStart.current = null;
      setDragging(false);
    };
    window.addEventListener("pointermove", move);
    window.addEventListener("pointerup", up);
    window.addEventListener("pointercancel", up);
    return () => {
      window.removeEventListener("pointermove", move);
      window.removeEventListener("pointerup", up);
      window.removeEventListener("pointercancel", up);
    };
  }, [dragging]);

  const send = () => {
    const text = draft.trim();
    if (!text || thinking) return;
    setMessages(m => [...m, { role: "person", author: "You", text }]);
    setDraft("");
    setThinking(true);
  };

  const beginResize = (e: React.PointerEvent) => {
    dragStart.current = { x: e.clientX, width };
    setDragging(true);
  };

  const resizeByKeyboard = (e: React.KeyboardEvent) => {
    if (e.key === "ArrowRight") {
      setWidth(w => Math.min(MAX_WIDTH, w + 16));
    } else if (e.key === "ArrowLeft") {
      setWidth(w => Math.max(MIN_WIDTH, w - 16));
    } else {
      return;
    }
    e.preventDefault();
  };

  return (
    <div
      className="relative flex h-[440px] flex-col overflow-hidden rounded-xl border border-border-default bg-bg-main"
      style={{ width: `min(100%, ${width}px)` }}
    >
      <header className="flex items-baseline justify-between border-b border-border-default px-4 py-3">
        <p className="text-sm font-medium text-text-strong">Build chat</p>
        <p className="text-xs text-text-muted" role="status">
          {thinking ? "Appia is answering…" : "Appia · idle"}
        </p>
      </header>

      <div ref={logRef} role="log" aria-label="Conversation" aria-busy={thinking} className="flex-1 overflow-y-auto px-4 py-4">
        <ul className="flex flex-col gap-3">
          {messages.map((message, index) =>
            message.questions ? (
              /* The plan's questions (#300): a turn of the conversation, and
               * the composer below is where the answer goes. */
              <li key={index} className="rounded-lg border border-border-default p-3">
                <p className="text-sm font-semibold text-text-strong">
                  Before building, {message.questions.length} things:
                </p>
                <ul className="mt-1.5 list-disc space-y-1 pl-4 text-sm text-text-muted">
                  {message.questions.map(question => (
                    <li key={question}>{question}</li>
                  ))}
                </ul>
                <p className="mt-1.5 text-xs text-text-muted">Answer below — or say to decide for you.</p>
              </li>
            ) : message.role === "system" ? (
              <li
                key={index}
                className="rounded-lg bg-bg-surface-1 px-3 py-2 text-xs text-text-muted"
              >
                {message.text}
              </li>
            ) : message.role === "person" ? (
              <li key={index} className="flex flex-col items-end gap-1">
                {message.author && <span className="px-1 text-xs text-text-muted">{message.author}</span>}
                <span className="w-fit rounded-lg rounded-tr-sm bg-primary-default/10 px-3 py-2 text-sm whitespace-pre-wrap">
                  {message.text}
                </span>
              </li>
            ) : message.log ? (
              <li key={index}>
                <p className="mb-1 px-1 text-sm leading-relaxed text-text-strong">{message.text}</p>
                <BuildLog steps={message.log} />
              </li>
            ) : (
              <li key={index} className="px-1 text-sm leading-relaxed text-text-strong">
                {message.text}
              </li>
            ),
          )}

          {thinking && (
            <li className="flex items-center gap-2 px-1 text-xs text-text-muted">
              <Loader01 className="size-3.5 shrink-0 animate-spin" strokeWidth={1.75} />
              Appia is answering…
            </li>
          )}
        </ul>
      </div>

      <div className="border-t border-border-default p-2.5">
        <InputGroup>
          <InputGroupTextarea
            rows={1}
            placeholder="Reply, or say to decide for you."
            aria-label="Message Appia"
            value={draft}
            onChange={(e) => setDraft(e.target.value)}
            onKeyDown={(e) => {
              if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
                e.preventDefault();
                send();
              }
            }}
          />
          <InputGroupAddon align="block-end" className="justify-end">
            <InputGroupButton
              variant="primary"
              size="icon-sm"
              className="rounded-full"
              aria-label="Send"
              disabled={!draft.trim() || thinking}
              onClick={send}
            >
              <ArrowUp className="size-4" strokeWidth={1.75} />
            </InputGroupButton>
          </InputGroupAddon>
        </InputGroup>
      </div>

      {/* The right edge is the resize affordance: a hairline grip that appears
       *  on hover, a full-height hot zone, and a separator for the keyboard. */}
      <div
        role="separator"
        aria-orientation="vertical"
        aria-label="Resize the chat area"
        aria-valuemin={MIN_WIDTH}
        aria-valuemax={MAX_WIDTH}
        aria-valuenow={width}
        tabIndex={0}
        onPointerDown={beginResize}
        onKeyDown={resizeByKeyboard}
        className={`group absolute inset-y-0 right-0 z-10 flex w-3 cursor-ew-resize touch-none items-center justify-center outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring-neutral-outline ${
          dragging ? "before:absolute before:inset-y-0 before:right-0 before:w-0.5 before:bg-primary-default" : ""
        }`}
      >
        <span
          aria-hidden="true"
          className={`h-10 w-1 rounded-full bg-border-active/50 transition-opacity ${
            dragging ? "opacity-100" : "opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100"
          }`}
        />
      </div>
    </div>
  );
}

View code

Input Group · Chat input vietnamese

Installation

pnpm dlx velaui add @velaui/input-group
pnpm dlx velaui add @velaui/input-group

Code

input-group-chat-input-vietnamese.tsx
import { useEffect, useId, useState } from "react";

import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { InformationCircleContained } from "@/components/icons/information-circle-contained";
import {
  useVietnameseInput,
  VietnameseMethods,
} from "@/hooks/use-vietnamese-input";

// Demo phrasebook: the example has no translation service behind it.
const PHRASEBOOK: Record<string, string> = {
  "gửi phiếu lương tháng 9 cho toàn bộ nhân viên":
    "Send the September payslips to all employees",
  "xin chào": "Hello",
  "cảm ơn bạn": "Thank you",
};

type Status = "empty" | "typing" | "translated" | "unknown";

/**
 * The one-box translate input from Appia's "Translate input · 1 ô" study:
 * type Vietnamese with Telex and, after a short pause, the box swaps the text
 * for its English translation. The hint line under the box follows the state
 * — Empty ("Tự động dịch sang tiếng Anh khi bạn gõ"), Typing ("Đang dịch…",
 * focused ring) and Translated ("Đã dịch từ tiếng Việt · Hoàn tác") — and
 * Hoàn tác puts the Vietnamese back. Try "Xin chào" or "Cảm ơn bạn".
 */
export default function ChatInputVietnameseExample() {
  const id = useId();
  const [value, setValue] = useState("");
  const [original, setOriginal] = useState<string | null>(null);
  const [status, setStatus] = useState<Status>("empty");
  const { ref } = useVietnameseInput({ method: VietnameseMethods.TELEX });

  useEffect(() => {
    if (status !== "typing") return;
    const timer = window.setTimeout(() => {
      const english = PHRASEBOOK[value.trim().toLowerCase()];
      if (!english) {
        setStatus("unknown");
        return;
      }
      setOriginal(value);
      setValue(english);
      setStatus("translated");
    }, 900);
    return () => window.clearTimeout(timer);
  }, [status, value]);

  const undo = () => {
    if (original === null) return;
    setValue(original);
    setOriginal(null);
    setStatus("typing");
  };

  return (
    <div className="grid w-full max-w-sm gap-1.5">
      <Label htmlFor={id} className="font-bold text-text-muted">
        Nội dung (tiếng Anh)
      </Label>
      <Input
        id={id}
        ref={ref}
        size="md"
        placeholder="Gõ tiếng Việt, tự chuyển sang tiếng Anh"
        value={value}
        aria-describedby={`${id}-hint`}
        onChange={(event) => {
          setValue(event.target.value);
          setOriginal(null);
          setStatus(event.target.value.trim() ? "typing" : "empty");
        }}
      />
      <p
        id={`${id}-hint`}
        role="status"
        className="flex items-center gap-1 text-xs leading-[1.3] text-text-note"
      >
        <InformationCircleContained size={16} aria-hidden className="shrink-0" />
        {status === "empty" && "Tự động dịch sang tiếng Anh khi bạn gõ"}
        {status === "typing" && "Đang dịch sang tiếng Anh…"}
        {status === "unknown" && "Chưa có bản dịch mẫu cho câu này"}
        {status === "translated" && (
          <span>
            Đã dịch từ tiếng Việt ·{" "}
            <button
              type="button"
              onClick={undo}
              className="font-semibold text-text-strong underline"
            >
              Hoàn tác
            </button>
          </span>
        )}
      </p>
    </div>
  );
}

View code

Input Group · Chat input vietnamese error

Installation

pnpm dlx velaui add @velaui/input-group
pnpm dlx velaui add @velaui/input-group

Code

input-group-chat-input-vietnamese-error.tsx
import { useId, useState } from "react";

import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { InformationCircleContained } from "@/components/icons/information-circle-contained";
import {
  useVietnameseInput,
  VietnameseMethods,
} from "@/hooks/use-vietnamese-input";

/**
 * The error state of the one-box translate input: the translation failed, so
 * the box keeps the Vietnamese the user typed, draws its negative border
 * through aria-invalid and the hint line turns into the failure message with
 * a Thử lại action. Telex stays on, and editing the text clears the error.
 */
export default function ChatInputVietnameseErrorExample() {
  const id = useId();
  const [value, setValue] = useState("Gửi phiếu lương tháng 9 cho toàn bộ nhân viên");
  const [failed, setFailed] = useState(true);
  const { ref } = useVietnameseInput({ method: VietnameseMethods.TELEX });

  const retry = () => {
    setValue("Send the September payslips to all employees");
    setFailed(false);
  };

  return (
    <div className="grid w-full max-w-sm gap-1.5">
      <Label htmlFor={id} className="font-bold text-text-muted">
        Nội dung (tiếng Anh)
      </Label>
      <Input
        id={id}
        ref={ref}
        size="md"
        placeholder="Gõ tiếng Việt, tự chuyển sang tiếng Anh"
        value={value}
        aria-invalid={failed}
        aria-describedby={`${id}-hint`}
        onChange={(event) => {
          setValue(event.target.value);
          setFailed(false);
        }}
      />
      <p
        id={`${id}-hint`}
        role={failed ? "alert" : "status"}
        className={
          failed
            ? "flex items-center gap-1 text-xs leading-[1.3] text-negative-strong"
            : "flex items-center gap-1 text-xs leading-[1.3] text-text-note"
        }
      >
        <InformationCircleContained size={16} aria-hidden className="shrink-0" />
        {failed ? (
          <span>
            Không dịch được lúc này ·{" "}
            <button type="button" onClick={retry} className="font-semibold underline">
              Thử lại
            </button>
          </span>
        ) : (
          "Đã dịch từ tiếng Việt"
        )}
      </p>
    </div>
  );
}

View code

Input Group · Chat input vietnamese disabled

Installation

pnpm dlx velaui add @velaui/input-group
pnpm dlx velaui add @velaui/input-group

Code

input-group-chat-input-vietnamese-disabled.tsx
import { useId } from "react";

import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { InformationCircleContained } from "@/components/icons/information-circle-contained";

/**
 * The disabled state of the one-box translate input: the box reads
 * border-disabled/bg-input-disabled and the hint line drops to text-disabled.
 * Used while a conversation is locked; no input method is attached because
 * nothing can be typed.
 */
export default function ChatInputVietnameseDisabledExample() {
  const id = useId();

  return (
    <div className="grid w-full max-w-sm gap-1.5">
      <Label htmlFor={id} className="font-bold text-text-muted">
        Nội dung (tiếng Anh)
      </Label>
      <Input
        id={id}
        size="md"
        disabled
        placeholder="Gõ tiếng Việt, tự chuyển sang tiếng Anh"
        aria-describedby={`${id}-hint`}
      />
      <p
        id={`${id}-hint`}
        className="flex items-center gap-1 text-xs leading-[1.3] text-text-disabled"
      >
        <InformationCircleContained size={16} aria-hidden className="shrink-0" />
        Tự động dịch sang tiếng Anh khi bạn gõ
      </p>
    </div>
  );
}

Sizes

Usage

import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
} from "@/components/ui/input-group";
import { Label } from "@/components/ui/label";
import { Ruler } from "@/components/icons/ruler";

export default function Example() {
  return (
    <div className="space-y-2">
      <Label>Input group sizes</Label>
      <InputGroup size={"sm"}>
        <InputGroupAddon>
          <Ruler />
        </InputGroupAddon>
        <InputGroupInput placeholder="small" />
      </InputGroup>
      <InputGroup size={"md"}>
        <InputGroupAddon>
          <Ruler />
        </InputGroupAddon>
        <InputGroupInput placeholder="medium" />
      </InputGroup>
      <InputGroup size={"lg"}>
        <InputGroupAddon>
          <Ruler />
        </InputGroupAddon>
        <InputGroupInput placeholder="large" />
      </InputGroup>
    </div>
  );
}

Props

InputGroup

PropTypeDefault
size"sm" | "md" | "lg""sm"
variant"fill" | "underline" | "outlined""outlined"

Takes these and every prop of InputGroupProps.

InputGroupAddon

PropTypeDefault
align"inline-end" | "inline-start" | "block-start" | "block-end""inline-start"
size"sm" | "md" | "lg"—

Takes these and every prop of InputGroupAddonProps.

InputGroupButton

PropTypeDefault
asChildboolean—
size"sm" | "xs" | "icon-xs" | "icon-sm""xs"
type"button" | "submit" | "reset""button"
variant"outline" | "link" | "destructive" | "ghost" | "primary" | "secondary""ghost"

Takes these and every prop of InputGroupButtonProps.

InputGroupText

Takes every prop of React.ComponentProps<"span">.

InputGroupInput

PropTypeDefault
size"sm" | "md" | "lg"—
variant"fill" | "underline" | "outlined"—

Takes these and every prop of InputProps.

InputGroupTextarea

Takes every prop of React.ComponentProps<"textarea">.

InputGroup

PropTypeDefault
size"sm" | "md" | "lg""sm"
variant"underline" | "fill" | "outlined""outlined"

Takes these and every prop of InputGroupProps.

InputGroupAddon

PropTypeDefault
align"inline-end" | "inline-start" | "block-start" | "block-end""inline-start"
size"sm" | "md" | "lg"—

Takes these and every prop of InputGroupAddonProps.

InputGroupButton

PropTypeDefault
size"sm" | "xs" | "icon-xs" | "icon-sm""xs"
type"button" | "submit" | "reset""button"
variant"link" | "outline" | "destructive" | "ghost" | "primary" | "secondary""ghost"

Takes these and every prop of InputGroupButtonProps.

InputGroupText

Takes every prop of React.ComponentProps<"span">.

InputGroupInput

PropTypeDefault
size"sm" | "md" | "lg"—
variant"underline" | "fill" | "outlined"—

Takes these and every prop of InputProps.

InputGroupTextarea

Takes every prop of React.ComponentProps<"textarea">.

Dependencies

npm
class-variance-authority@0.7.1
Installs with it
ButtonInputTextarea

Source

components/ui/input-group.tsx
/* eslint-disable react-refresh/only-export-components */
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, type InputProps } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";

type InputGroupSize = "sm" | "md" | "lg" | null | undefined;
type InputGroupVariant = "outlined" | "fill" | "underline" | null | undefined;
type InputGroupContextType = {
  size?: InputGroupSize;
  variant?: InputGroupVariant;
};

const InputGroupContext = React.createContext<InputGroupContextType | null>(
  null,
);

function InputGroupProvider({
  children,
  size,
  variant,
}: {
  children: React.ReactNode;
  size: InputGroupSize;
  variant: InputGroupVariant;
}) {
  const value = React.useMemo(() => ({ size, variant }), [size, variant]);
  return (
    <InputGroupContext value={value}>
      {children}
    </InputGroupContext>
  );
}

function useInputGroup() {
  const context = React.useContext(InputGroupContext);
  if (!context) {
    throw new Error("useInputGroup must be used inside InputGroupProvider.");
  }
  return context;
}

const inputGroupVariants = cva(
  "group/input-group relative flex w-full min-w-0 items-center text-sm leading-[1.4] transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 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",
  {
    variants: {
      variant: {
        outlined:
          "rounded-md border border-border-default bg-bg-main hover:border-border-hover hover:bg-input-hover has-[[data-slot=input-group-control]:focus-visible]:border-ring-neutral-outline has-[[data-slot=input-group-control]:focus-visible]:bg-input-default has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring-neutral-shadow has-disabled:border-border-disabled has-disabled:bg-input-disabled has-[[data-slot][aria-invalid=true]]:border-negative-middle has-[[data-slot][aria-invalid=true]:focus-visible]:border-negative-middle has-[[data-slot][aria-invalid=true]:focus-visible]:ring-negative-middle/25",
        // Fill draws no border at rest, but hover/focus/invalid all draw a 1px
        // one — reserve it as transparent so entering a state never reflows.
        fill: "rounded-md border border-transparent bg-input-default hover:border-border-hover hover:bg-input-hover has-[[data-slot=input-group-control]:focus-visible]:border-ring-neutral-outline has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring-neutral-shadow has-disabled:border-transparent has-disabled:bg-input-disabled has-[[data-slot][aria-invalid=true]]:border-negative-middle has-[[data-slot][aria-invalid=true]:focus-visible]:border-negative-middle has-[[data-slot][aria-invalid=true]:focus-visible]:ring-negative-middle/25",
        // Underline carries no surface, no radius and no focus ring — the whole
        // state vocabulary lives on the bottom border.
        underline:
          "rounded-none border-0 border-b border-b-border-default bg-transparent hover:border-b-border-active has-[[data-slot=input-group-control]:focus-visible]:border-b-border-active has-disabled:border-b-border-default has-disabled:bg-transparent has-[[data-slot][aria-invalid=true]]:border-b-negative-middle has-[[data-slot][aria-invalid=true]:focus-visible]:border-b-negative-middle",
      },
      size: {
        sm: "h-8",
        md: "h-10",
        lg: "h-12",
      },
    },
    defaultVariants: {
      variant: "outlined",
      size: "sm",
    },
  },
);

export type InputGroupProps = React.ComponentProps<"div"> & VariantProps<typeof inputGroupVariants>;

function InputGroup({
  className,
  variant = "outlined",
  size = "sm",
  ...props
}: InputGroupProps) {
  return (
    <InputGroupProvider size={size} variant={variant}>
      <div
        data-slot="input-group"
        data-variant={variant}
        data-size={size}
        role="group"
        className={cn(inputGroupVariants({ variant, size }), className)}
        {...props}
      />
    </InputGroupProvider>
  );
}

const inputGroupAddonVariants = cva(
  "flex h-auto cursor-text items-center justify-center gap-2 text-sm text-text-muted select-none group-data-[disabled=true]/input-group:opacity-50 group-has-[:disabled]/input-group:text-text-disabled in-data-[variant=underline]:px-0 [&>kbd]:rounded-xs [&>svg:not([class*='size-'])]:size-4",
  {
    variants: {
      align: {
        "inline-start": "",
        "inline-end": "",
        "block-start": "",
        "block-end": "",
      },
      size: {
        sm: "",
        md: "",
        lg: "",
      },
    },
    compoundVariants: [
      {
        align: "inline-start",
        size: "sm",
        class:
          "order-first py-1.5 pl-2.5 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
      },
      {
        align: "inline-start",
        size: "md",
        class:
          "order-first py-2.5 pl-3 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
      },
      {
        align: "inline-start",
        size: "lg",
        class:
          "order-first py-3.5 pl-4 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
      },

      {
        align: "inline-end",
        size: "sm",
        class:
          "order-last py-1.5 pr-2.5 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
      },
      {
        align: "inline-end",
        size: "md",
        class:
          "order-last py-2.5 pr-3 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
      },
      {
        align: "inline-end",
        size: "lg",
        class:
          "order-last py-3.5 pr-4 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
      },

      {
        align: "block-start",
        size: "sm",
        class:
          "order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
      },
      {
        align: "block-start",
        size: "md",
        class:
          "order-first w-full justify-start px-3 pt-2.5 group-has-[>input]/input-group:pt-2.5 [.border-b]:pb-2",
      },
      {
        align: "block-start",
        size: "lg",
        class:
          "order-first w-full justify-start px-4 pt-3.5 group-has-[>input]/input-group:pt-3.5 [.border-b]:pb-2",
      },

      {
        align: "block-end",
        size: "sm",
        class:
          "order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
      },
      {
        align: "block-end",
        size: "md",
        class:
          "order-last w-full justify-start px-3 pb-2.5 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
      },
      {
        align: "block-end",
        size: "lg",
        class:
          "order-last w-full justify-start px-4 pb-3.5 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
      },
    ],
  },
);

export type InputGroupAddonProps = React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>;

function InputGroupAddon({
  className,
  align = "inline-start",
  ...props
}: InputGroupAddonProps) {
  const { size } = useInputGroup();
  return (
    <div
      role="group"
      data-slot="input-group-addon"
      data-align={align}
      className={cn(inputGroupAddonVariants({ align, size }), 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-xs shadow-none",
  {
    variants: {
      size: {
        xs: "h-6 gap-1 rounded-sm px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
        sm: "",
        "icon-xs": "size-4 rounded-sm p-0 has-[>svg]:p-0",
        "icon-sm": "size-6 p-0 has-[>svg]:p-0",
      },
    },
    defaultVariants: {
      size: "xs",
    },
  },
);

export type InputGroupButtonProps = Omit<React.ComponentProps<typeof Button>, "size"> &
  VariantProps<typeof inputGroupButtonVariants>;

function InputGroupButton({
  className,
  type = "button",
  variant = "ghost",
  size = "xs",
  ...props
}: InputGroupButtonProps) {

  return (
    <Button
      type={type}
      data-size={size}
      variant={variant}
      size="sm"
      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-text-muted [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
        className,
      )}
      {...props}
    />
  );
}

function InputGroupInput({ className, ...props }: InputProps) {
  const { size, variant } = useInputGroup();

  return (
    <Input
      data-slot="input-group-control"
      variant={variant}
      size={size}
      className={cn(
        // The group owns the chrome — the control renders bare in every state.
        "flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 hover:border-transparent hover:bg-transparent focus-visible:border-transparent focus-visible:bg-transparent focus-visible:ring-0 disabled:bg-transparent aria-invalid:border-transparent aria-invalid:ring-0",
        className,
      )}
      {...props}
    />
  );
}

const inputGroupTextareaVariants = cva(
  "flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 hover:border-transparent hover:bg-transparent focus-visible:border-transparent focus-visible:bg-transparent focus-visible:ring-0 disabled:bg-transparent aria-invalid:border-transparent aria-invalid:ring-0",
  {
    variants: {
      size: {
        sm: "px-2.5",
        md: "px-3",
        lg: "px-4",
      },
    },
    defaultVariants: {
      size: "sm",
    },
  },
);

function InputGroupTextarea({
  className,
  ...props
}: React.ComponentProps<"textarea">) {
  const { size } = useInputGroup();

  return (
    <Textarea
      data-slot="input-group-control"
      className={cn(inputGroupTextareaVariants({ size }), className)}
      {...props}
    />
  );
}

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