Vela UI Docsvenizia.ai

Vietnamese Input

A text input with a built-in Vietnamese input method: Telex and VNI keystrokes become diacritics in place as you type, with modern tone placement, and pass-through when disabled. The method prop switches between telex and vni; the use-vietnamese-input hook drives any input or textarea without an OS IME.

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

Examples

  • Default
  • Method toggle
  • Chat vietnamese

View code

Vietnamese Input · Default

Installation

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

Code

vietnamese-input-default.tsx
import { Label } from "@/components/ui/label";
import { VietnameseInput } from "@/components/ui/vietnamese-input";
import { useId, useState } from "react";

export default function Example() {
  const id = useId();
  const [value, setValue] = useState("");

  return (
    <div className="w-full max-w-xs space-y-2">
      <Label htmlFor={id}>Vietnamese input</Label>
      <VietnameseInput
        id={id}
        placeholder="Xin chao ban"
        value={value}
        onChange={(event) => setValue(event.target.value)}
      />
      <p className="text-text-muted text-xs">
        Telex is on: <code>aa</code> â, <code>dd</code> đ, <code>ow</code> ơ, <code>as</code> á,{" "}
        <code>af</code> à, <code>aj</code> ạ, <code>az</code> strips the tone.
      </p>
    </div>
  );
}

View code

Vietnamese Input · Method toggle

Installation

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

Code

vietnamese-input-method-toggle.tsx
import { Label } from "@/components/ui/label";
import { Segmented, SegmentedItem } from "@/components/ui/segmented";
import { VietnameseInput } from "@/components/ui/vietnamese-input";
import { VietnameseMethods, type TVietnameseMethod } from "@/hooks/use-vietnamese-input";
import { useId, useState } from "react";

export default function Example() {
  const id = useId();
  const [method, setMethod] = useState<TVietnameseMethod>(VietnameseMethods.TELEX);
  const [value, setValue] = useState("");

  return (
    <div className="w-full max-w-xs space-y-2">
      <Label htmlFor={id}>Vietnamese input</Label>
      <Segmented
        value={method}
        onValueChange={(next) => {
          if (VietnameseMethods.isValid(next)) {
            setMethod(next);
          }
        }}
        aria-label="Input method"
      >
        <SegmentedItem value={VietnameseMethods.TELEX}>Telex</SegmentedItem>
        <SegmentedItem value={VietnameseMethods.VNI}>VNI</SegmentedItem>
      </Segmented>
      <VietnameseInput
        id={id}
        method={method}
        placeholder={
          method === VietnameseMethods.TELEX ? "chao -> chào, dd -> đ" : "a1 -> á, a6 -> â, d9 -> đ"
        }
        value={value}
        onChange={(event) => setValue(event.target.value)}
      />
      <p className="text-text-muted text-xs">
        Switch the method, then type - the field converts keystrokes in place.
      </p>
    </div>
  );
}

View code

Vietnamese Input · Chat vietnamese

Installation

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

Code

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

import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { ArrowSwitchHorizontal } from "@/components/icons/arrow-switch-horizontal";
import { Close01 } from "@/components/icons/close-01";
import { CopyRight } from "@/components/icons/copy-right";
import { Microphone02 } from "@/components/icons/microphone-02";
import {
  useVietnameseInput,
  VietnameseMethods,
} from "@/hooks/use-vietnamese-input";

const LIMIT = 5000;

// Demo phrasebook: the example has no translation service behind it.
const PAIRS: Array<[vi: string, en: string]> = [
  [
    "Gửi phiếu lương tháng 9 cho toàn bộ nhân viên trước ngày 05/10.",
    "Send the September payslips to all employees before October 5.",
  ],
  ["Xin chào", "Hello"],
  ["Cảm ơn bạn", "Thank you"],
];

type Language = "vi" | "en";

const LANGUAGE_NAMES: Record<Language, string> = { vi: "Tiếng Việt", en: "Tiếng Anh" };

const translate = (text: string, from: Language) => {
  const key = text.trim().toLowerCase();
  const pair = PAIRS.find(([vi, en]) => (from === "vi" ? vi : en).toLowerCase() === key);
  return pair ? pair[from === "vi" ? 1 : 0] : null;
};

/**
 * The translate box from Appia's "Translate box" study: a language bar with a
 * swap button, a source field (Telex on while the source is Vietnamese, clear
 * and voice buttons, a character counter) and a muted translation panel under
 * it. The panel walks Empty → Translating ("Đang dịch…") → Translated, where
 * the copy button appears. Try "Xin chào" or "Cảm ơn bạn".
 */
export default function Example() {
  const id = useId();
  const [source, setSource] = useState<Language>("vi");
  const [value, setValue] = useState("");
  const [output, setOutput] = useState<string | null>(null);
  const [copied, setCopied] = useState(false);
  const target: Language = source === "vi" ? "en" : "vi";
  const translating = value.trim() !== "" && output === null;
  const { ref } = useVietnameseInput({
    method: VietnameseMethods.TELEX,
    enabled: source === "vi",
  });

  useEffect(() => {
    if (!value.trim()) return;
    const timer = window.setTimeout(
      () => setOutput(translate(value, source) ?? "Chưa có bản dịch mẫu cho câu này"),
      700,
    );
    return () => window.clearTimeout(timer);
  }, [value, source]);

  const edit = (next: string) => {
    setValue(next.slice(0, LIMIT));
    setOutput(null);
    setCopied(false);
  };

  const swap = () => {
    setSource(target);
    edit(output && translate(value, source) ? output : "");
  };

  const copy = () => {
    void navigator.clipboard?.writeText(output ?? "");
    setCopied(true);
  };

  return (
    <div className="w-full max-w-lg overflow-clip rounded-2xl border border-border-default bg-bg-main">
      <div className="flex items-center justify-between border-b border-border-default p-2">
        <span className="px-3 text-sm leading-[1.4] font-semibold text-text-strong">
          {LANGUAGE_NAMES[source]}
        </span>
        <Button variant="ghost" size="icon-sm" aria-label="Đảo ngôn ngữ" onClick={swap}>
          <ArrowSwitchHorizontal />
        </Button>
        <span className="px-3 text-sm leading-[1.4] font-semibold text-text-strong">
          {LANGUAGE_NAMES[target]}
        </span>
      </div>

      <div className="flex flex-col gap-4 pt-4 pr-3 pb-3 pl-5">
        <div className="flex min-h-14 items-start gap-2">
          <Textarea
            id={id}
            ref={ref}
            variant="underline"
            aria-label={`Văn bản ${LANGUAGE_NAMES[source].toLowerCase()}`}
            placeholder="Nhập văn bản để dịch…"
            value={value}
            onChange={(event) => edit(event.target.value)}
            className="min-h-14 flex-1 resize-none border-0 bg-transparent p-0 text-base focus-visible:ring-0 hover:bg-transparent focus-visible:bg-transparent"
          />
          {value && (
            <Button variant="ghost" size="icon-sm" aria-label="Xoá" onClick={() => edit("")}>
              <Close01 />
            </Button>
          )}
        </div>
        <div className="flex items-center justify-between">
          <Button variant="ghost" size="icon-sm" aria-label="Nói">
            <Microphone02 />
          </Button>
          <span className="text-[10px] leading-[1.3] font-medium text-text-note">
            {value.length.toLocaleString("vi-VN")} / {LIMIT.toLocaleString("vi-VN")}
          </span>
        </div>
      </div>

      <div className="flex flex-col gap-2 border-t border-border-default bg-secondary-disabled pt-3.5 pr-3 pb-3 pl-5">
        <p
          className="flex items-center gap-1.5 text-[10px] leading-[1.3] font-semibold text-text-note uppercase"
        >
          {translating && (
            <span
              aria-hidden
              className="size-3 shrink-0 animate-spin rounded-full border-2 border-border-strong border-t-transparent"
            />
          )}
          {LANGUAGE_NAMES[target]} · tự động dịch
        </p>
        <p
          role="status"
          className={`min-h-14 text-base leading-[1.4] ${output ? "text-text-strong" : "text-text-note"}`}
        >
          {output ?? (translating ? "Đang dịch…" : "Bản dịch sẽ hiện ở đây")}
        </p>
        <div className="flex h-8 justify-end">
          {output && (
            <Button
              variant="ghost"
              size="icon-sm"
              aria-label={copied ? "Đã sao chép" : "Sao chép"}
              onClick={copy}
            >
              <CopyRight />
            </Button>
          )}
        </div>
      </div>
    </div>
  );
}

Usage

import { Label } from "@/components/ui/label";
import { VietnameseInput } from "@/components/ui/vietnamese-input";
import { useId, useState } from "react";

export default function Example() {
  const id = useId();
  const [value, setValue] = useState("");

  return (
    <div className="w-full max-w-xs space-y-2">
      <Label htmlFor={id}>Vietnamese input</Label>
      <VietnameseInput
        id={id}
        placeholder="Xin chao ban"
        value={value}
        onChange={(event) => setValue(event.target.value)}
      />
      <p className="text-text-muted text-xs">
        Telex is on: <code>aa</code> â, <code>dd</code> đ, <code>ow</code> ơ, <code>as</code> á,{" "}
        <code>af</code> à, <code>aj</code> ạ, <code>az</code> strips the tone.
      </p>
    </div>
  );
}

Props

PropTypeDefault
enabledbooleantrue
methodimport("/app/catalog/hooks/use-vietnamese-input").TVietnameseMethodVietnameseMethods.TELEX
size"sm" | "md" | "lg"—
variant"fill" | "underline" | "outlined"—

Takes these and every prop of VietnameseInputProps.

PropTypeDefault
enabledbooleantrue
methodimport("/app/catalog/hooks/use-vietnamese-input").TVietnameseMethodVietnameseMethods.TELEX
size"sm" | "md" | "lg"—
variant"underline" | "fill" | "outlined"—

Takes these and every prop of VietnameseInputProps.

Dependencies

npm
none besides React
Installs with it
Input

Source

components/ui/vietnamese-input.tsx
import { Input, type InputProps } from "@/components/ui/input";
import {
  useVietnameseInput,
  VietnameseMethods,
  type VietnameseInputOptions,
} from "@/hooks/use-vietnamese-input";

export type VietnameseInputProps = InputProps & VietnameseInputOptions;

function VietnameseInput({
  method = VietnameseMethods.TELEX,
  enabled = true,
  ref,
  ...props
}: VietnameseInputProps) {
  const { ref: imeRef } = useVietnameseInput({ method, enabled });
  return (
    <Input
      {...props}
      ref={(element) => {
        const detach = imeRef(element);
        if (typeof ref === "function") {
          ref(element);
        } else if (ref) {
          ref.current = element;
        }
        return detach;
      }}
      data-slot="vietnamese-input"
      data-method={method}
    />
  );
}

export { VietnameseInput };