# Tag input

> A record's tags with suggestions, where typing a name adds a tag or creates it.

Source: https://bettersupabase.com/docs/registry/ui-recipes/tag-input

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

Both sides call `useTagInput` from `better-supabase/blocks/tags/react`; the [tags block](https://bettersupabase.com/blocks/tags) must be installed (`npx better-supabase add tags`). Its page on bettersupabase.com is [/recipes/tag-input](https://bettersupabase.com/recipes/tag-input).

Web, with shadcn components:

```bash
npx shadcn add badge button input skeleton
```

```tsx title="apps/examples/nextjs/src/features/customers/components/tag-input.tsx"
"use client";

import type { SubmitEvent } from "react";

import { useTagInput } from "better-supabase/blocks/tags/react";
import { XIcon } from "lucide-react";
import { useExtracted } from "next-intl";
import { useId, useState } from "react";

import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { useErrorMessage } from "@/lib/use-error-message";

/**
 * A record's tags. Typing a name adds the organization's tag of the subject's
 * kind with that name, or creates it for callers with `tags.manage`.
 */
export function TagInput({
  subject,
}: {
  subject: { readonly type: string; readonly id: string };
}) {
  const t = useExtracted("customers");
  const listId = useId();
  const errorMessage = useErrorMessage();
  const [name, setName] = useState("");

  const { tags, available, pending, error, create, remove } = useTagInput({
    subject,
    kind: subject.type,
  });

  const applied = new Set(tags?.map((tag) => tag.id));
  const suggestions = available?.filter((tag) => !applied.has(tag.id)) ?? [];

  const submit = async (event: SubmitEvent<HTMLFormElement>) => {
    event.preventDefault();

    if (!name.trim()) {
      return;
    }

    if ((await create(name)).ok) {
      setName("");
    }
  };

  if (tags === undefined) {
    return <Skeleton className="h-16" />;
  }

  return (
    <div className="space-y-3">
      <ul className="flex flex-wrap gap-1.5" data-testid="tags">
        {tags.length === 0 ? (
          <li className="text-sm text-muted-foreground">{t("No tags yet.")}</li>
        ) : null}
        {tags.map((tag) => (
          <li key={tag.id}>
            <Badge
              variant="secondary"
              className={pending.includes(tag.id) ? "opacity-50" : undefined}
            >
              {tag.name}
              <button
                type="button"
                aria-label={t("Remove {name}", { name: tag.name })}
                disabled={pending.includes(tag.id)}
                onClick={() => {
                  void remove(tag.id);
                }}
              >
                <XIcon className="size-3" />
              </button>
            </Badge>
          </li>
        ))}
      </ul>
      <form onSubmit={(event) => void submit(event)} className="flex gap-2">
        <Input
          value={name}
          onChange={(event) => {
            setName(event.target.value);
          }}
          list={listId}
          maxLength={80}
          placeholder={t("Add a tag")}
          aria-label={t("Tag")}
        />
        <datalist id={listId}>
          {suggestions.map((tag) => (
            <option key={tag.id} value={tag.name}>
              {tag.name}
            </option>
          ))}
        </datalist>
        <Button type="submit" variant="outline" disabled={!name.trim()}>
          {t("Add")}
        </Button>
      </form>
      {error ? (
        <p className="text-sm text-destructive">{errorMessage(error)}</p>
      ) : null}
    </div>
  );
}
```

React Native, with `ActivityIndicator`, `Button`, `Pressable`, `Text`, `TextInput`, `View` from `react-native`:

```tsx title="apps/examples/expo-powersync/src/components/tag-input.tsx"
import { useTagInput } from "better-supabase/blocks/tags/react";
import { useState } from "react";
import {
  ActivityIndicator,
  Button,
  Pressable,
  Text,
  TextInput,
  View,
} from "react-native";

/**
 * A customer's tags as chips that remove themselves when pressed, the
 * organization's other tags as suggestions, and a field that adds or creates
 * one by name.
 */
export function TagInput({ customerId }: { readonly customerId: string }) {
  const [name, setName] = useState("");

  const { tags, available, pending, error, add, remove, create } = useTagInput({
    subject: { type: "customer", id: customerId },
    kind: "customer",
  });

  if (tags === undefined) {
    return <ActivityIndicator />;
  }

  const applied = new Set(tags.map((tag) => tag.id));
  const suggestions = (available ?? []).filter((tag) => !applied.has(tag.id));

  const chip = (label: string, busy: boolean, onPress: () => void) => (
    <Pressable
      key={label}
      accessibilityRole="button"
      disabled={busy}
      onPress={onPress}
      style={{
        borderWidth: 1,
        borderRadius: 16,
        paddingHorizontal: 10,
        paddingVertical: 2,
        opacity: busy ? 0.5 : 1,
      }}
    >
      <Text>{label}</Text>
    </Pressable>
  );

  return (
    <View style={{ gap: 8 }}>
      <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 6 }}>
        {tags.length === 0 ? <Text>No tags yet.</Text> : null}
        {tags.map((tag) =>
          chip(`${tag.name} ×`, pending.includes(tag.id), () => {
            void remove(tag.id);
          }),
        )}
      </View>
      <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 6 }}>
        {suggestions.map((tag) =>
          chip(`+ ${tag.name}`, pending.includes(tag.id), () => {
            void add(tag.id);
          }),
        )}
      </View>
      {error ? <Text style={{ color: "#b91c1c" }}>{error.message}</Text> : null}
      <TextInput
        value={name}
        onChangeText={setName}
        placeholder="Add a tag"
        maxLength={80}
        style={{ borderWidth: 1, borderRadius: 8, padding: 8 }}
      />
      <Button
        title="Add"
        disabled={!name.trim()}
        onPress={() =>
          void create(name).then((result) => {
            if (result.ok) {
              setName("");
            }
          })
        }
      />
    </View>
  );
}
```