Forms
Common form patterns used in Studio settings pages and side panels.
Forms in Supabase Studio should follow consistent patterns to ensure a cohesive user experience across settings pages and side panels. This guide covers the most common form patterns and field types.
Page Layout
Forms in page layouts typically use PageSection components with Card containers. Fields use FormItemLayout with layout="flex-row-reverse" for horizontal alignment.
import { zodResolver } from '@hookform/resolvers/zod'
import { format } from 'date-fns'
import { CalendarIcon, ExternalLink, Trash, Upload } from 'lucide-react'
import { useRef, useState } from 'react'
import { useForm } from 'react-hook-form'
import {
Button,
Calendar,
Card,
CardContent,
CardFooter,
Checkbox,
Form,
FormControl,
FormField,
FormInputGroupInput,
FormInputGroupTextArea,
Input,
InputGroup,
InputGroupAddon,
InputGroupText,
Popover,
PopoverContent,
PopoverTrigger,
RadioGroupStacked,
RadioGroupStackedItem,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Switch,
Textarea,
} from 'ui'
import { Input as PasswordInput } from 'ui-patterns/DataInputs/Input'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { KeyValueFieldArray } from 'ui-patterns/form/KeyValueFieldArray/KeyValueFieldArray'
import { getKeyValueFieldArrayValidationIssues } from 'ui-patterns/form/KeyValueFieldArray/validation'
import { SingleValueFieldArray } from 'ui-patterns/form/SingleValueFieldArray/SingleValueFieldArray'
import {
MultiSelector,
MultiSelectorContent,
MultiSelectorItem,
MultiSelectorList,
MultiSelectorTrigger,
} from 'ui-patterns/multi-select'
import {
PageSection,
PageSectionContent,
PageSectionMeta,
PageSectionSummary,
PageSectionTitle,
} from 'ui-patterns/PageSection'
import * as z from 'zod'
const formSchema = z
.object({
name: z.string().min(1, 'Name is required'),
description: z.string().optional(),
maxConnections: z.number().min(1).max(1000),
enableFeature: z.boolean(),
enableRls: z.boolean(),
enableNotifications: z.boolean(),
enableAnalytics: z.boolean(),
region: z.string().min(1, 'Region is required'),
schemas: z.array(z.string()).min(1, 'At least one schema is required'),
queueType: z.enum(['basic', 'partitioned']),
expiryDate: z.date().optional(),
password: z.string().min(8, 'Password must be at least 8 characters'),
duration: z.number().min(5).max(30),
redirectUris: z.array(z.object({ value: z.string().url('Must be a valid URL') })),
httpHeaders: z.array(z.object({ key: z.string().trim(), value: z.string().trim() })),
apiKey: z.string().optional(),
})
.superRefine((data, ctx) => {
getKeyValueFieldArrayValidationIssues({
rows: data.httpHeaders,
keyFieldName: 'key',
valueFieldName: 'value',
keyRequiredMessage: 'Header name is required',
valueRequiredMessage: 'Header value is required',
}).forEach((issue) => {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: issue.message,
path: ['httpHeaders', ...issue.path],
})
})
})
const fakeApiKey = 'sk_live_51H3x4mpl3_4nd_53cur3_k3y_1234567890'
export function FormPatternsPageLayout() {
const uploadButtonRef = useRef<HTMLInputElement>(null)
const fileUploadRef = useRef<HTMLInputElement>(null)
const [logoFile, setLogoFile] = useState<File>()
const [logoUrl, setLogoUrl] = useState<string>()
const [uploadedFiles, setUploadedFiles] = useState<File[]>([])
const [isDragging, setIsDragging] = useState(false)
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: '',
description: '',
maxConnections: 10,
enableFeature: false,
enableRls: true,
enableNotifications: false,
enableAnalytics: true,
region: '',
schemas: ['public'],
queueType: 'basic',
expiryDate: undefined,
password: '',
duration: 10,
redirectUris: [{ value: '' }],
httpHeaders: [{ key: '', value: '' }],
apiKey: fakeApiKey,
},
})
function onSubmit(values: z.infer<typeof formSchema>) {
console.log(values)
}
return (
<div className="w-full">
<PageSection className="py-0">
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Form Settings</PageSectionTitle>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<Card>
{/* Text Input */}
<CardContent>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Text Input"
description="Single-line text entry for short values"
>
<FormControl>
<Input {...field} placeholder="Enter text" />
</FormControl>
</FormItemLayout>
)}
/>
</CardContent>
{/* Password Input */}
<CardContent>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Password Input"
description="Masked input for secure text entry"
>
<FormControl>
<Input {...field} type="password" placeholder="Enter password" />
</FormControl>
</FormItemLayout>
)}
/>
</CardContent>
{/* Copyable Input */}
<CardContent>
<FormField
control={form.control}
name="apiKey"
render={() => (
<FormItemLayout
layout="flex-row-reverse"
label="Copyable Input"
description="Read-only input with copy-to-clipboard functionality"
>
<FormControl>
<PasswordInput
copy
readOnly
value={form.getValues('apiKey') || ''}
onChange={() => {}}
onCopy={() => console.log('Copied to clipboard')}
/>
</FormControl>
</FormItemLayout>
)}
/>
</CardContent>
{/* Number Input */}
<CardContent>
<FormField
control={form.control}
name="maxConnections"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Number Input"
description="Numeric input with min/max validation"
>
<FormControl>
<Input
{...field}
type="number"
min={1}
max={1000}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
</FormItemLayout>
)}
/>
</CardContent>
{/* Input with Units */}
<CardContent>
<FormField
control={form.control}
name="duration"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Input with Units"
description="Input with additional unit label"
>
<FormControl>
<InputGroup>
<FormInputGroupInput
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
type="number"
min={5}
max={30}
/>
<InputGroupAddon align="inline-end">
<InputGroupText className="font-mono">MB</InputGroupText>
</InputGroupAddon>
</InputGroup>
</FormControl>
</FormItemLayout>
)}
/>
</CardContent>
{/* Textarea */}
<CardContent>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Textarea"
description="Multi-line text input for longer content"
>
<FormControl>
<Textarea
{...field}
rows={4}
placeholder="Enter multi-line text"
className="resize-none"
/>
</FormControl>
</FormItemLayout>
)}
/>
</CardContent>
{/* Textarea with addon */}
<CardContent>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Textarea"
description="Multi-line text input for longer content with addon"
>
<FormControl>
<InputGroup>
<FormInputGroupTextArea
{...field}
rows={4}
placeholder="Enter multi-line text"
className="resize-none"
/>
<InputGroupAddon align="block-end">
<InputGroupText>120 characters left</InputGroupText>
</InputGroupAddon>
</InputGroup>
</FormControl>
</FormItemLayout>
)}
/>
</CardContent>
{/* Icon Upload */}
<CardContent>
<FormField
control={form.control}
name="description"
render={() => (
<FormItemLayout
layout="flex-row-reverse"
label="Icon upload"
description="For icons, avatars, or small images with preview"
>
<FormControl>
<div className="flex gap-4 items-center">
<button
type="button"
onClick={() => uploadButtonRef.current?.click()}
className="flex items-center justify-center h-10 w-10 shrink-0 text-foreground-lighter hover:text-foreground-light overflow-hidden rounded-full bg-cover border hover:border-strong"
style={{
backgroundImage: logoUrl ? `url("${logoUrl}")` : 'none',
}}
>
{!logoUrl && <Upload size={14} />}
</button>
<div className="flex gap-2 items-center">
<Button
type="default"
size="tiny"
icon={<Upload size={14} />}
onClick={() => uploadButtonRef.current?.click()}
>
Upload
</Button>
{logoUrl && (
<Button
type="default"
size="tiny"
icon={<Trash size={12} />}
onClick={() => {
setLogoFile(undefined)
setLogoUrl(undefined)
}}
/>
)}
</div>
<input
type="file"
ref={uploadButtonRef}
className="hidden"
accept="image/png, image/jpeg"
onChange={(e) => {
const files = e.target.files
if (files && files.length > 0) {
const file = files[0]
setLogoFile(file)
setLogoUrl(URL.createObjectURL(file))
e.target.value = ''
}
}}
/>
</div>
</FormControl>
</FormItemLayout>
)}
/>
</CardContent>
{/* File Upload */}
<CardContent>
<FormField
control={form.control}
name="description"
render={() => (
<FormItemLayout
layout="flex-row-reverse"
label="File Upload"
description="Drag-and-drop or select files for upload"
>
<FormControl>
<div
className={`border-2 rounded-lg p-6 text-center bg-muted transition-colors duration-300 ${
isDragging
? 'border-strong border-dashed bg-muted'
: 'border-border border-dashed'
}`}
onDragOver={(e) => {
e.preventDefault()
setIsDragging(true)
}}
onDragLeave={() => setIsDragging(false)}
onDrop={(e) => {
e.preventDefault()
setIsDragging(false)
const files = Array.from(e.dataTransfer.files)
setUploadedFiles((prev) => [...prev, ...files])
}}
>
<input
type="file"
ref={fileUploadRef}
className="hidden"
multiple
onChange={(e) => {
const files = e.target.files
if (files) {
setUploadedFiles((prev) => [...prev, ...Array.from(files)])
}
e.target.value = ''
}}
/>
<div className="flex flex-col items-center gap-y-2">
<Upload size={20} className="text-foreground-lighter" />
<p className="text-sm text-foreground-light">
{uploadedFiles.length > 0
? `${uploadedFiles.length} file${uploadedFiles.length > 1 ? 's' : ''} selected`
: 'Upload files'}
</p>
<p className="text-xs text-foreground-lighter">
Drag and drop or{' '}
<button
type="button"
onClick={() => fileUploadRef.current?.click()}
className="underline cursor-pointer hover:text-foreground-light"
>
select files
</button>{' '}
to upload
</p>
{uploadedFiles.length > 0 && (
<div className="mt-4 w-full space-y-2">
{uploadedFiles.map((file, idx) => (
<div
key={`${file.name}-${idx}`}
className="flex items-center justify-between gap-2 p-2 bg rounded-sm border"
>
<span className="text-sm text-foreground-light truncate flex-1">
{file.name}
</span>
<Button
type="default"
size="tiny"
icon={<Trash size={12} />}
onClick={() => {
setUploadedFiles((prev) =>
prev.filter((_, i) => i !== idx)
)
}}
/>
</div>
))}
</div>
)}
</div>
</div>
</FormControl>
</FormItemLayout>
)}
/>
</CardContent>
{/* Switch */}
<CardContent>
<FormField
control={form.control}
name="enableFeature"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Switch"
description="Toggle for boolean on/off states"
>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItemLayout>
)}
/>
</CardContent>
{/* Checkbox */}
<CardContent>
<FormItemLayout
layout="flex-row-reverse"
label="Checkbox"
description="Boolean values or multiple selections"
>
<div className="w-full flex flex-col gap-4">
<FormField
control={form.control}
name="enableRls"
render={({ field }) => (
<div className="flex items-center w-full justify-start space-x-2">
<FormControl>
<Checkbox
id="enable-rls"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<label
htmlFor="enable-rls"
className="text-sm text-foreground-light leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
Enable Row Level Security
</label>
</div>
)}
/>
<FormField
control={form.control}
name="enableNotifications"
render={({ field }) => (
<div className="flex items-center w-full justify-start space-x-2">
<FormControl>
<Checkbox
id="enable-notifications"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<label
htmlFor="enable-notifications"
className="text-sm text-foreground-light leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
Enable email notifications
</label>
</div>
)}
/>
<FormField
control={form.control}
name="enableAnalytics"
render={({ field }) => (
<div className="flex items-center w-full justify-start space-x-2">
<FormControl>
<Checkbox
id="enable-analytics"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<label
htmlFor="enable-analytics"
className="text-sm text-foreground-light leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
Enable analytics tracking
</label>
</div>
)}
/>
</div>
</FormItemLayout>
</CardContent>
{/* Select */}
<CardContent>
<FormField
control={form.control}
name="region"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Select (Dropdown)"
description="Single selection from a list of options"
>
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select an option" />
</SelectTrigger>
<SelectContent>
<SelectItem value="us-east-1">US East (N. Virginia)</SelectItem>
<SelectItem value="us-west-2">US West (Oregon)</SelectItem>
<SelectItem value="eu-west-1">EU West (Ireland)</SelectItem>
</SelectContent>
</Select>
</FormControl>
</FormItemLayout>
)}
/>
</CardContent>
{/* Multi-Select */}
<CardContent>
<FormField
control={form.control}
name="schemas"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Multi-Select"
description="Multiple selection from a list"
>
<MultiSelector
onValuesChange={field.onChange}
values={field.value}
size="small"
>
<MultiSelectorTrigger
mode="inline-combobox"
label="Select options..."
badgeLimit="wrap"
showIcon={false}
deletableBadge
className="w-full"
/>
<MultiSelectorContent>
<MultiSelectorList>
<MultiSelectorItem value="public">public</MultiSelectorItem>
<MultiSelectorItem value="auth">auth</MultiSelectorItem>
<MultiSelectorItem value="storage">storage</MultiSelectorItem>
</MultiSelectorList>
</MultiSelectorContent>
</MultiSelector>
</FormItemLayout>
)}
/>
</CardContent>
{/* Radio Group */}
<CardContent>
<FormField
control={form.control}
name="queueType"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Radio Group"
description="Single selection from multiple options"
>
<FormControl>
<RadioGroupStacked value={field.value} onValueChange={field.onChange}>
<RadioGroupStackedItem
value="basic"
label="Option 1"
description="First option description"
/>
<RadioGroupStackedItem
value="partitioned"
label="Option 2"
description="Second option description"
/>
</RadioGroupStacked>
</FormControl>
</FormItemLayout>
)}
/>
</CardContent>
{/* Date Picker */}
<CardContent>
<FormField
control={form.control}
name="expiryDate"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Date Picker"
description="Date selection with calendar popover"
>
<FormControl>
<Popover>
<PopoverTrigger asChild>
<Button
type="outline"
className="bg-control w-full justify-start text-left font-normal px-3 py-4"
icon={<CalendarIcon className="h-4 w-4" />}
>
{field.value ? format(field.value, 'PPP') : 'Pick a date'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={field.value}
onSelect={field.onChange}
initialFocus
/>
</PopoverContent>
</Popover>
</FormControl>
</FormItemLayout>
)}
/>
</CardContent>
{/* Field Array */}
<CardContent>
<FormField
control={form.control}
name="redirectUris"
render={() => (
<FormItemLayout
layout="flex-row-reverse"
label="Field Array"
description="Dynamic list for adding/removing items"
>
<SingleValueFieldArray
control={form.control}
name="redirectUris"
valueFieldName="value"
createEmptyRow={() => ({ value: '' })}
placeholder="https://example.com/callback"
addLabel="Add redirect URI"
removeLabel="Remove redirect URI"
/>
</FormItemLayout>
)}
/>
</CardContent>
{/* Key/Value Field Array */}
<CardContent>
<FormField
control={form.control}
name="httpHeaders"
render={() => (
<FormItemLayout
layout="flex-row-reverse"
label="Key/Value Field Array"
description="Repeated text pairs for headers, parameters, and config entries"
>
<KeyValueFieldArray
control={form.control}
name="httpHeaders"
keyFieldName="key"
valueFieldName="value"
createEmptyRow={() => ({ key: '', value: '' })}
keyPlaceholder="Header name"
valuePlaceholder="Header value"
addLabel="Add header"
removeLabel="Remove header"
/>
</FormItemLayout>
)}
/>
</CardContent>
{/* Action Field */}
<CardContent>
<FormItemLayout
layout="flex-row-reverse"
label="Action Field"
description="Button or link for navigation or performable actions"
>
<div className="flex gap-2 items-center justify-end">
<Button
type="default"
icon={<ExternalLink size={14} />}
onClick={() => console.log('Action performed')}
>
View documentation
</Button>
<Button type="default" onClick={() => console.log('Reset action')}>
Reset API key
</Button>
</div>
</FormItemLayout>
</CardContent>
<CardFooter className="justify-end space-x-2">
{form.formState.isDirty && (
<Button type="default" onClick={() => form.reset()}>
Cancel
</Button>
)}
<Button type="primary" htmlType="submit" disabled={!form.formState.isDirty}>
Save changes
</Button>
</CardFooter>
</Card>
</form>
</Form>
</PageSectionContent>
</PageSection>
</div>
)
}Side Panel
Forms in side panels (Sheets) use FormItemLayout with layout="horizontal" on wider panels and layout="vertical" on panels with a size of sm or below. The form is typically wrapped in a Sheet component.
import { zodResolver } from '@hookform/resolvers/zod'
import { format } from 'date-fns'
import { CalendarIcon, ExternalLink, Trash, Upload } from 'lucide-react'
import { useRef, useState } from 'react'
import { useForm } from 'react-hook-form'
import {
Button,
Calendar,
Checkbox,
Form,
FormControl,
FormField,
FormInputGroupInput,
Input,
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
Popover,
PopoverContent,
PopoverTrigger,
RadioGroupStacked,
RadioGroupStackedItem,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Separator,
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetSection,
SheetTitle,
Switch,
Textarea,
} from 'ui'
import { Input as PasswordInput } from 'ui-patterns/DataInputs/Input'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { KeyValueFieldArray } from 'ui-patterns/form/KeyValueFieldArray/KeyValueFieldArray'
import { getKeyValueFieldArrayValidationIssues } from 'ui-patterns/form/KeyValueFieldArray/validation'
import { SingleValueFieldArray } from 'ui-patterns/form/SingleValueFieldArray/SingleValueFieldArray'
import {
MultiSelector,
MultiSelectorContent,
MultiSelectorItem,
MultiSelectorList,
MultiSelectorTrigger,
} from 'ui-patterns/multi-select'
import * as z from 'zod'
const formSchema = z
.object({
name: z.string().min(1, 'Name is required'),
description: z.string().optional(),
maxConnections: z.number().min(1).max(1000),
enableFeature: z.boolean(),
enableRls: z.boolean(),
enableNotifications: z.boolean(),
enableAnalytics: z.boolean(),
region: z.string().min(1, 'Region is required'),
schemas: z.array(z.string()).min(1, 'At least one schema is required'),
queueType: z.enum(['basic', 'partitioned']),
expiryDate: z.date().optional(),
password: z.string().min(8, 'Password must be at least 8 characters'),
duration: z.number().min(5).max(30),
redirectUris: z.array(z.object({ value: z.string().url('Must be a valid URL') })),
httpHeaders: z.array(z.object({ key: z.string().trim(), value: z.string().trim() })),
apiKey: z.string().optional(),
})
.superRefine((data, ctx) => {
getKeyValueFieldArrayValidationIssues({
rows: data.httpHeaders,
keyFieldName: 'key',
valueFieldName: 'value',
keyRequiredMessage: 'Header name is required',
valueRequiredMessage: 'Header value is required',
}).forEach((issue) => {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: issue.message,
path: ['httpHeaders', ...issue.path],
})
})
})
const fakeApiKey = 'sk_live_51H3x4mpl3_4nd_53cur3_k3y_1234567890'
export function FormPatternsSidePanel() {
const [open, setOpen] = useState(false)
const uploadButtonRef = useRef<HTMLInputElement>(null)
const fileUploadRef = useRef<HTMLInputElement>(null)
const [logoFile, setLogoFile] = useState<File>()
const [logoUrl, setLogoUrl] = useState<string>()
const [uploadedFiles, setUploadedFiles] = useState<File[]>([])
const [isDragging, setIsDragging] = useState(false)
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: '',
description: '',
maxConnections: 10,
enableFeature: false,
enableRls: true,
enableNotifications: false,
enableAnalytics: true,
region: '',
schemas: ['public'],
queueType: 'basic',
expiryDate: undefined,
password: '',
duration: 10,
redirectUris: [{ value: '' }],
httpHeaders: [{ key: '', value: '' }],
apiKey: fakeApiKey,
},
})
function onSubmit(values: z.infer<typeof formSchema>) {
console.log(values)
setOpen(false)
}
const formId = 'sidepanel-form'
return (
<>
<Button type="primary" onClick={() => setOpen(true)}>
Open form panel
</Button>
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent size="lg" className="flex flex-col gap-0">
<SheetHeader>
<SheetTitle>Create Configuration</SheetTitle>
</SheetHeader>
<Form {...form}>
<form
id={formId}
onSubmit={form.handleSubmit(onSubmit)}
className="overflow-auto grow px-0"
>
{/* Text Input */}
<SheetSection>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label="Text Input"
description="Single-line text entry for short values"
>
<FormControl className="col-span-6">
<Input {...field} placeholder="Enter text" />
</FormControl>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator className="-mx-5 w-[calc(100%+2.5rem)]" />
{/* Password Input */}
<SheetSection>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label="Password Input"
description="Masked input for secure text entry"
>
<FormControl className="col-span-6">
<Input {...field} type="password" placeholder="Enter password" />
</FormControl>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator className="-mx-5 w-[calc(100%+2.5rem)]" />
{/* Copyable Input */}
<SheetSection>
<FormField
control={form.control}
name="apiKey"
render={() => (
<FormItemLayout
layout="horizontal"
label="Copyable Input"
description="Read-only input with copy-to-clipboard functionality"
>
<FormControl className="col-span-6">
<PasswordInput
copy
readOnly
className="input-mono"
value={form.getValues('apiKey') || ''}
onChange={() => {}}
onCopy={() => console.log('Copied to clipboard')}
/>
</FormControl>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator className="-mx-5 w-[calc(100%+2.5rem)]" />
{/* Number Input */}
<SheetSection>
<FormField
control={form.control}
name="maxConnections"
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label="Number Input"
description="Numeric input with min/max validation"
>
<FormControl className="col-span-6">
<Input
{...field}
type="number"
min={1}
max={1000}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator className="-mx-5 w-[calc(100%+2.5rem)]" />
{/* Input with Units */}
<SheetSection>
<FormField
control={form.control}
name="duration"
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label="Input with Units"
description="Input with additional unit label"
>
<FormControl className="col-span-6">
<InputGroup>
<FormInputGroupInput {...field} type="number" min={5} max={30} />
<InputGroupAddon align="inline-end">
<InputGroupText>MB</InputGroupText>
</InputGroupAddon>
</InputGroup>
</FormControl>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator className="-mx-5 w-[calc(100%+2.5rem)]" />
{/* Textarea */}
<SheetSection>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label="Textarea"
description="Multi-line text input for longer content"
>
<FormControl className="col-span-6">
<Textarea
{...field}
rows={3}
placeholder="Enter multi-line text"
className="resize-none"
/>
</FormControl>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator className="-mx-5 w-[calc(100%+2.5rem)]" />
{/* Icon Upload */}
<SheetSection>
<FormField
control={form.control}
name="description"
render={() => (
<FormItemLayout
layout="horizontal"
label="Icon upload"
description="For icons, avatars, or small images with preview"
>
<FormControl className="col-span-6">
<div className="flex gap-4 items-center">
<button
type="button"
onClick={() => uploadButtonRef.current?.click()}
className="flex items-center justify-center h-10 w-10 shrink-0 text-foreground-lighter hover:text-foreground-light overflow-hidden rounded-full bg-cover border hover:border-strong"
style={{
backgroundImage: logoUrl ? `url("${logoUrl}")` : 'none',
}}
>
{!logoUrl && <Upload size={14} />}
</button>
<div className="flex gap-2 items-center">
<Button
type="default"
size="tiny"
icon={<Upload size={14} />}
onClick={() => uploadButtonRef.current?.click()}
>
Upload
</Button>
{logoUrl && (
<Button
type="default"
size="tiny"
icon={<Trash size={12} />}
onClick={() => {
setLogoFile(undefined)
setLogoUrl(undefined)
}}
/>
)}
</div>
<input
type="file"
ref={uploadButtonRef}
className="hidden"
accept="image/png, image/jpeg"
onChange={(e) => {
const files = e.target.files
if (files && files.length > 0) {
const file = files[0]
setLogoFile(file)
setLogoUrl(URL.createObjectURL(file))
e.target.value = ''
}
}}
/>
</div>
</FormControl>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator className="-mx-5 w-[calc(100%+2.5rem)]" />
{/* File Upload */}
<SheetSection>
<FormField
control={form.control}
name="description"
render={() => (
<FormItemLayout
layout="horizontal"
label="File Upload"
description="Drag-and-drop or select files for upload"
>
<FormControl className="col-span-6">
<div
className={`border-2 rounded-lg p-6 text-center bg-muted transition-colors duration-300 ${
isDragging
? 'border-strong border-dashed bg-muted'
: 'border-border border-dashed'
}`}
onDragOver={(e) => {
e.preventDefault()
setIsDragging(true)
}}
onDragLeave={() => setIsDragging(false)}
onDrop={(e) => {
e.preventDefault()
setIsDragging(false)
const files = Array.from(e.dataTransfer.files)
setUploadedFiles((prev) => [...prev, ...files])
}}
>
<input
type="file"
ref={fileUploadRef}
className="hidden"
multiple
onChange={(e) => {
const files = e.target.files
if (files) {
setUploadedFiles((prev) => [...prev, ...Array.from(files)])
}
e.target.value = ''
}}
/>
<div className="flex flex-col items-center gap-y-2">
<Upload size={20} className="text-foreground-lighter" />
<p className="text-sm text-foreground-light">
{uploadedFiles.length > 0
? `${uploadedFiles.length} file${uploadedFiles.length > 1 ? 's' : ''} selected`
: 'Upload files'}
</p>
<p className="text-xs text-foreground-lighter">
Drag and drop or{' '}
<button
type="button"
onClick={() => fileUploadRef.current?.click()}
className="underline cursor-pointer hover:text-foreground-light"
>
select files
</button>{' '}
to upload
</p>
{uploadedFiles.length > 0 && (
<div className="mt-4 w-full space-y-2">
{uploadedFiles.map((file, idx) => (
<div
key={`${file.name}-${idx}`}
className="flex items-center justify-between gap-2 p-2 bg rounded-sm border"
>
<span className="text-sm text-foreground-light truncate flex-1">
{file.name}
</span>
<Button
type="default"
size="tiny"
icon={<Trash size={12} />}
onClick={() => {
setUploadedFiles((prev) => prev.filter((_, i) => i !== idx))
}}
/>
</div>
))}
</div>
)}
</div>
</div>
</FormControl>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator className="-mx-5 w-[calc(100%+2.5rem)]" />
{/* Switch */}
<SheetSection>
<FormField
control={form.control}
name="enableFeature"
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label="Switch"
description="Toggle for boolean on/off states"
>
<FormControl className="col-span-6">
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItemLayout>
)}
/>
</SheetSection>
{/* Checkbox */}
<SheetSection>
<FormItemLayout
layout="horizontal"
label="Checkbox"
description="Boolean values or multiple selections"
>
<div className="col-span-6 w-full flex flex-col gap-4">
<FormField
control={form.control}
name="enableRls"
render={({ field }) => (
<div className="flex items-center w-full justify-start space-x-2">
<FormControl>
<Checkbox
id="enable-rls"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<label
htmlFor="enable-rls"
className="text-sm text-foreground-light leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
Enable Row Level Security
</label>
</div>
)}
/>
<FormField
control={form.control}
name="enableNotifications"
render={({ field }) => (
<div className="flex items-center w-full justify-start space-x-2">
<FormControl>
<Checkbox
id="enable-notifications"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<label
htmlFor="enable-notifications"
className="text-sm text-foreground-light leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
Enable email notifications
</label>
</div>
)}
/>
<FormField
control={form.control}
name="enableAnalytics"
render={({ field }) => (
<div className="flex items-center w-full justify-start space-x-2">
<FormControl>
<Checkbox
id="enable-analytics"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<label
htmlFor="enable-analytics"
className="text-sm text-foreground-light leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
Enable analytics tracking
</label>
</div>
)}
/>
</div>
</FormItemLayout>
</SheetSection>
<Separator className="-mx-5 w-[calc(100%+2.5rem)]" />
{/* Select */}
<SheetSection>
<FormField
control={form.control}
name="region"
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label="Select (Dropdown)"
description="Single selection from a list of options"
>
<FormControl className="col-span-6">
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select an option" />
</SelectTrigger>
<SelectContent>
<SelectItem value="us-east-1">US East (N. Virginia)</SelectItem>
<SelectItem value="us-west-2">US West (Oregon)</SelectItem>
<SelectItem value="eu-west-1">EU West (Ireland)</SelectItem>
</SelectContent>
</Select>
</FormControl>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator className="-mx-5 w-[calc(100%+2.5rem)]" />
{/* Multi-Select */}
<SheetSection>
<FormField
control={form.control}
name="schemas"
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label="Multi-Select"
description="Multiple selection from a list"
>
<div className="col-span-6">
<MultiSelector
onValuesChange={field.onChange}
values={field.value}
size="small"
className="w-full"
>
<MultiSelectorTrigger
mode="inline-combobox"
label="Select options..."
badgeLimit="wrap"
showIcon={false}
deletableBadge
className="w-full min-w-lg!"
/>
<MultiSelectorContent>
<MultiSelectorList>
<MultiSelectorItem value="public">public</MultiSelectorItem>
<MultiSelectorItem value="auth">auth</MultiSelectorItem>
<MultiSelectorItem value="storage">storage</MultiSelectorItem>
</MultiSelectorList>
</MultiSelectorContent>
</MultiSelector>
</div>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator className="-mx-5 w-[calc(100%+2.5rem)]" />
{/* Radio Group */}
<SheetSection>
<FormField
control={form.control}
name="queueType"
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label="Radio Group"
description="Single selection from multiple options"
>
<div className="col-span-6">
<RadioGroupStacked value={field.value} onValueChange={field.onChange}>
<RadioGroupStackedItem
value="basic"
label="Option 1"
description="First option description"
/>
<RadioGroupStackedItem
value="partitioned"
label="Option 2"
description="Second option description"
/>
</RadioGroupStacked>
</div>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator className="-mx-5 w-[calc(100%+2.5rem)]" />
{/* Date Picker */}
<SheetSection>
<FormField
control={form.control}
name="expiryDate"
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label="Date Picker"
description="Date selection with calendar popover"
>
<FormControl className="col-span-6">
<Popover>
<PopoverTrigger asChild>
<Button
type="outline"
className="bg-control w-full justify-start text-left font-normal px-3 py-4"
icon={<CalendarIcon className="h-4 w-4" />}
>
{field.value ? format(field.value, 'PPP') : 'Pick a date'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={field.value}
onSelect={field.onChange}
initialFocus
/>
</PopoverContent>
</Popover>
</FormControl>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator className="-mx-5 w-[calc(100%+2.5rem)]" />
{/* Field Array */}
<SheetSection>
<FormField
control={form.control}
name="redirectUris"
render={() => (
<FormItemLayout
layout="horizontal"
label="Field Array"
description="Dynamic list for adding/removing items"
>
<div className="col-span-6">
<SingleValueFieldArray
control={form.control}
name="redirectUris"
valueFieldName="value"
createEmptyRow={() => ({ value: '' })}
placeholder="https://example.com/callback"
addLabel="Add redirect URI"
removeLabel="Remove redirect URI"
/>
</div>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator className="-mx-5 w-[calc(100%+2.5rem)]" />
{/* Key/Value Field Array */}
<SheetSection>
<FormField
control={form.control}
name="httpHeaders"
render={() => (
<FormItemLayout
layout="horizontal"
label="Key/Value Field Array"
description="Repeated text pairs for headers, parameters, and config entries"
>
<div className="col-span-6">
<KeyValueFieldArray
control={form.control}
name="httpHeaders"
keyFieldName="key"
valueFieldName="value"
createEmptyRow={() => ({ key: '', value: '' })}
keyPlaceholder="Header name"
valuePlaceholder="Header value"
addLabel="Add header"
removeLabel="Remove header"
/>
</div>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator className="-mx-5 w-[calc(100%+2.5rem)]" />
{/* Action Field */}
<SheetSection>
<FormItemLayout
layout="horizontal"
label="Action Field"
description="Button or link for navigation or performable actions"
>
<div className="col-span-6 flex gap-2 items-center">
<Button
type="default"
icon={<ExternalLink size={14} />}
onClick={() => console.log('Action performed')}
>
View documentation
</Button>
<Button type="default" onClick={() => console.log('Reset action')}>
Reset API key
</Button>
</div>
</FormItemLayout>
</SheetSection>
</form>
</Form>
<SheetFooter>
<Button
type="default"
onClick={() => {
form.reset()
setOpen(false)
}}
>
Cancel
</Button>
<Button type="primary" form={formId} htmlType="submit">
Create
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
</>
)
}Field Arrays
The form previews above include both repeated-field patterns used across Studio:
- Field Array for repeated single-value rows such as redirect URIs.
- Key/Value Field Array for repeated text pairs such as headers, parameters, and config entries.
Use the shared Single Value Field Array fragment when each row is one text input managed by react-hook-form.
Use the shared Key/Value Field Array fragment when each row is two text inputs managed by react-hook-form.
Keep repeated-row validation in the form schema or shared validation helper, not in the fragment component itself.
Build a custom row when the cells are mixed controls, such as an input paired with a Select.
Best Practices
-
Always use FormItemLayout: Use
FormItemLayoutinstead of manually composingFormItem,FormLabel,FormMessage, andFormDescription. -
Layout selection:
- Use
layout="flex-row-reverse"for page layouts (horizontal alignment) - Use
layout="horizontal"for side panels with more width - Use
layout="vertical"for side panels with limited width
- Use
-
Wrap inputs in FormControlShadcn: Always wrap form inputs with
FormControlto ensure proper form integration. -
Use Cards for grouping: Wrap form sections in
Cardcomponents withCardContentandCardFooterfor actions. -
Handle dirty state: Show cancel buttons and disable save buttons based on
form.formState.isDirty. Make sure you destructureisDirtyfromform.formState(see https://react-hook-form.com/docs/useform/formstate) -
Error handling: Always use mutations with
onSuccessandonErrorcallbacks that show toast notifications. -
Loading states: Show loading states on submit buttons using the
loadingprop. -
Form IDs: When submit buttons are outside the form, use a form ID and reference it with the
formprop on the button.