All recipes
Recipe
Tag input
A record's tags with suggestions, where typing a name adds a tag or creates it. Web and native call useTagInput from better-supabase/blocks/tags/react.
1.What you build
No screenshot yet. Run the Next.js example (pnpm dev:example) to see it.
2.Add the components
Web: shadcn
npx shadcn add badge button input skeletonNative: React Native
The snippet uses ActivityIndicator, Button, Pressable, Text, TextInput, View from react-native, so it needs no styling library. For shadcn-style native components, React Native Reusables serves them from a registry the shadcn CLI reads.
3.Install the glue
Install the block with npx better-supabase sql add tags. The glue items are copied into your app, so edit them as you need.
Web
None: the hook calls the block from the client.
Native
None: the hook calls the block from the client.
4.Write the component
Web: React and shadcn
View in the example"use client";
import { useTagInput } from "better-supabase/blocks/tags/react";
import { XIcon } from "lucide-react";
import { useExtracted } from "next-intl";
import { type SubmitEvent, 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 { blockConfig } from "@/lib/supabase/blocks.generated";
import { useErrorMessage } from "@/lib/use-error-message";
/**
* A customer's tags. Typing a name adds the organization's tag with that
* name, or creates it for callers with `tags.manage`.
*/
export function TagInput({
organizationId,
customerId,
}: {
organizationId: string;
customerId: string;
}) {
const t = useExtracted("customers");
const listId = useId();
const errorMessage = useErrorMessage();
const [name, setName] = useState("");
const { tags, available, pending, error, create, remove } = useTagInput({
organizationId,
subjectType: "customer",
subjectId: customerId,
kind: "customer",
schema: blockConfig.tags.api,
});
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-muted-foreground text-sm">{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-destructive text-sm">{errorMessage(error)}</p>
) : null}
</div>
);
}
Native: React Native
View in the exampleimport { useTagInput } from "better-supabase/blocks/tags/react";
import { tenantOf, useAuth } from "better-supabase/react";
import { useState } from "react";
import {
ActivityIndicator,
Button,
Pressable,
Text,
TextInput,
View,
} from "react-native";
import { API_SCHEMA } from "../lib/blocks";
/**
* 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({
organizationId: tenantOf(useAuth()),
subjectType: "customer",
subjectId: customerId,
kind: "customer",
schema: API_SCHEMA,
});
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>
);
}