Skip to content
  1. Overlays
  2. Form dialog

Form dialog

A dialog holding a short form: title, a line of context, your fields, then Cancel and the submit verb.

'use client'

import { Button } from '@/components/button'
import { Checkbox, CheckboxField } from '@/components/checkbox'
import { Description, Field, FieldGroup, Label } from '@/components/fieldset'
import { FormDialog } from '@/components/form-dialog'
import { Textarea } from '@/components/textarea'
import { useEffect, useState } from 'react'

const maxLength = 2000

export default function Example() {
  let [open, setOpen] = useState(false)
  let [content, setContent] = useState('')
  let [pinned, setPinned] = useState(false)
  let [notes, setNotes] = useState<{ content: string; pinned: boolean }[]>([])
  let remaining = maxLength - content.length

  useEffect(() => {
    if (open) {
      setContent('')
      setPinned(false)
    }
  }, [open])

  return (
    <>
      <Button onClick={() => setOpen(true)}>Add note</Button>
      <ul role="list">
        {notes.map((note, index) => (
          <li key={index}>{note.content}</li>
        ))}
      </ul>
      <FormDialog
        open={open}
        onClose={() => setOpen(false)}
        onSubmit={() => {
          let note = { content: content.trim(), pinned }
          setNotes(pinned ? [note, ...notes] : [...notes, note])
          setOpen(false)
        }}
        title="Add note"
        description="Notes stay internal. The customer never sees them."
        submitLabel="Add note"
        submitDisabled={content.trim() === ''}
      >
        <FieldGroup>
          <Field>
            <Label>Note</Label>
            <Textarea
              name="content"
              rows={5}
              maxLength={maxLength}
              value={content}
              onChange={(event) => setContent(event.target.value)}
              placeholder="What should the team know about this customer?"
            />
            {remaining <= 200 && <Description>{remaining} characters left.</Description>}
          </Field>
          <CheckboxField>
            <Checkbox name="is_pinned" checked={pinned} onChange={setPinned} />
            <Label>Pin this note</Label>
            <Description>Pinned notes stay at the top of the list.</Description>
          </CheckboxField>
        </FieldGroup>
      </FormDialog>
    </>
  )
}
<script setup lang="ts">
import Button from '@/components/button.vue'
import Checkbox from '@/components/checkbox.vue'
import CheckboxField from '@/components/checkbox-field.vue'
import Description from '@/components/description.vue'
import Field from '@/components/field.vue'
import FieldGroup from '@/components/field-group.vue'
import FormDialog from '@/components/form-dialog.vue'
import Label from '@/components/label.vue'
import Textarea from '@/components/textarea.vue'
import { computed, reactive, ref, watch } from 'vue'

const maxLength = 2000
const open = ref(false)
const form = reactive({ content: '', pinned: false })
const notes = ref<{ content: string; pinned: boolean }[]>([])

watch(open, (value) => {
  if (value) {
    form.content = ''
    form.pinned = false
  }
})

const remaining = computed(() => maxLength - form.content.length)

function addNote() {
  const note = { content: form.content.trim(), pinned: form.pinned }
  notes.value = note.pinned ? [note, ...notes.value] : [...notes.value, note]
  open.value = false
}
</script>

<template>
  <Button @click="open = true">Add note</Button>
  <ul role="list">
    <li v-for="(note, index) in notes" :key="index">{{ note.content }}</li>
  </ul>
  <FormDialog
    :open="open"
    title="Add note"
    description="Notes stay internal. The customer never sees them."
    submit-label="Add note"
    :submit-disabled="form.content.trim() === ''"
    @close="open = false"
    @submit="addNote"
  >
    <FieldGroup>
      <Field>
        <Label>Note</Label>
        <Textarea
          v-model="form.content"
          name="content"
          rows="5"
          :maxlength="maxLength"
          placeholder="What should the team know about this customer?"
        />
        <Description v-if="remaining <= 200">{{ remaining }} characters left.</Description>
      </Field>
      <CheckboxField>
        <Checkbox v-model="form.pinned" name="is_pinned" />
        <Label>Pin this note</Label>
        <Description>Pinned notes stay at the top of the list.</Description>
      </CheckboxField>
    </FieldGroup>
  </FormDialog>
</template>

Installation

Copy these files into the folder where you keep your components. They import each other with relative paths, so the folder can live anywhere. The packages they rely on are listed on the Installation page.

import type React from 'react'
import { Button } from './button'
import { Dialog, DialogActions, DialogBody, DialogDescription, DialogTitle } from './dialog'

export function FormDialog({
  open,
  onClose,
  onSubmit,
  title,
  description,
  badge,
  size = 'lg',
  submitLabel,
  cancelLabel = 'Cancel',
  submitDisabled = false,
  color,
  children,
}: {
  open: boolean
  onClose: () => void
  onSubmit: (event: React.FormEvent<HTMLFormElement>) => void
  title: React.ReactNode
  description?: React.ReactNode
  badge?: React.ReactNode
  size?: React.ComponentProps<typeof Dialog>['size']
  submitLabel: React.ReactNode
  cancelLabel?: React.ReactNode
  submitDisabled?: boolean
  color?: React.ComponentProps<typeof Button>['color']
  children: React.ReactNode
}) {
  return (
    <Dialog open={open} onClose={() => onClose()} size={size}>
      {badge ? (
        <div className="flex items-start justify-between gap-4">
          <DialogTitle>{title}</DialogTitle>
          {badge}
        </div>
      ) : (
        <DialogTitle>{title}</DialogTitle>
      )}
      {description && <DialogDescription>{description}</DialogDescription>}
      <form
        onSubmit={(event) => {
          event.preventDefault()
          onSubmit(event)
        }}
      >
        <DialogBody>{children}</DialogBody>
        <DialogActions>
          <Button plain onClick={onClose}>
            {cancelLabel}
          </Button>
          <Button type="submit" color={color} disabled={submitDisabled}>
            {submitLabel}
          </Button>
        </DialogActions>
      </form>
    </Dialog>
  )
}

Also copy Button, Dialog.

<script setup lang="ts">
import Button from './button.vue'
import Dialog from './dialog.vue'
import DialogActions from './dialog-actions.vue'
import DialogBody from './dialog-body.vue'
import DialogDescription from './dialog-description.vue'
import DialogTitle from './dialog-title.vue'

type DialogSize = InstanceType<typeof Dialog>['$props']['size']
type ButtonColor = InstanceType<typeof Button>['$props']['color']

withDefaults(
  defineProps<{
    open: boolean
    title: string
    description?: string
    size?: DialogSize
    submitLabel: string
    cancelLabel?: string
    submitDisabled?: boolean
    color?: ButtonColor
  }>(),
  { size: 'lg', cancelLabel: 'Cancel', submitDisabled: false }
)

const emit = defineEmits<{ close: []; submit: [event: Event] }>()
</script>

<template>
  <Dialog :open="open" :size="size" @close="emit('close')">
    <div v-if="$slots.badge" class="flex items-start justify-between gap-4">
      <DialogTitle>{{ title }}</DialogTitle>
      <slot name="badge" />
    </div>
    <DialogTitle v-else>{{ title }}</DialogTitle>
    <DialogDescription v-if="description">{{ description }}</DialogDescription>
    <form @submit.prevent="emit('submit', $event)">
      <DialogBody>
        <slot />
      </DialogBody>
      <DialogActions>
        <Button plain @click="emit('close')">{{ cancelLabel }}</Button>
        <Button type="submit" :color="color" :disabled="submitDisabled">{{ submitLabel }}</Button>
      </DialogActions>
    </form>
  </Dialog>
</template>

Also copy Button, Dialog.

Component API

PropDefaultDescription
FormDialogbuilt on the <Dialog> component
open-Whether the dialog is open.
onClose-Called on Cancel, Escape and outside click.
onSubmit-Called when the form is submitted, by the button or Enter in a field, once native validation passes. Receives the submit event, default already prevented.
title-What the form does: "Add note", "Edit address".
description-One line of context under the title.
badge-Rendered opposite the title: a step counter, the type of the record.
sizelgForwarded to the Dialog. `xl` for two-column forms.
submitLabel-Verb and object: "Add note", "Save changes".
cancelLabelCancelLabel of the plain button.
submitDisabledfalseDisable submit until the required fields are filled.
color-Color of the submit button, the default button when unset. `red` for a destructive form.
PropDefaultDescription
FormDialogbuilt on the <Dialog> component
open-Whether the dialog is open.
@close-Emitted on Cancel, Escape and outside click.
@submit-Emitted when the form is submitted, by the button or Enter in a field, once native validation passes. Receives the submit event, default already prevented.
title-What the form does: "Add note", "Edit address".
description-One line of context under the title.
#badge-Slot rendered opposite the title: a step counter, the type of the record.
sizelgForwarded to the Dialog. `xl` for two-column forms.
submitLabel-Verb and object: "Add note", "Save changes".
cancelLabelCancelLabel of the plain button.
submitDisabledfalseDisable submit until the required fields are filled.
color-Color of the submit button, the default button when unset. `red` for a destructive form.

Examples

Basic example

The fields go in the default slot, in a FieldGroup. Reset them when open turns true, and disable submit until the required ones are filled. A character counter appears only in the last tenth of the limit:

'use client'

import { Button } from '@/components/button'
import { Checkbox, CheckboxField } from '@/components/checkbox'
import { Description, Field, FieldGroup, Label } from '@/components/fieldset'
import { FormDialog } from '@/components/form-dialog'
import { Textarea } from '@/components/textarea'
import { useEffect, useState } from 'react'

const maxLength = 2000

export default function Example() {
  let [open, setOpen] = useState(false)
  let [content, setContent] = useState('')
  let [pinned, setPinned] = useState(false)
  let [notes, setNotes] = useState<{ content: string; pinned: boolean }[]>([])
  let remaining = maxLength - content.length

  useEffect(() => {
    if (open) {
      setContent('')
      setPinned(false)
    }
  }, [open])

  return (
    <>
      <Button onClick={() => setOpen(true)}>Add note</Button>
      <ul role="list">
        {notes.map((note, index) => (
          <li key={index}>{note.content}</li>
        ))}
      </ul>
      <FormDialog
        open={open}
        onClose={() => setOpen(false)}
        onSubmit={() => {
          let note = { content: content.trim(), pinned }
          setNotes(pinned ? [note, ...notes] : [...notes, note])
          setOpen(false)
        }}
        title="Add note"
        description="Notes stay internal. The customer never sees them."
        submitLabel="Add note"
        submitDisabled={content.trim() === ''}
      >
        <FieldGroup>
          <Field>
            <Label>Note</Label>
            <Textarea
              name="content"
              rows={5}
              maxLength={maxLength}
              value={content}
              onChange={(event) => setContent(event.target.value)}
              placeholder="What should the team know about this customer?"
            />
            {remaining <= 200 && <Description>{remaining} characters left.</Description>}
          </Field>
          <CheckboxField>
            <Checkbox name="is_pinned" checked={pinned} onChange={setPinned} />
            <Label>Pin this note</Label>
            <Description>Pinned notes stay at the top of the list.</Description>
          </CheckboxField>
        </FieldGroup>
      </FormDialog>
    </>
  )
}
<script setup lang="ts">
import Button from '@/components/button.vue'
import Checkbox from '@/components/checkbox.vue'
import CheckboxField from '@/components/checkbox-field.vue'
import Description from '@/components/description.vue'
import Field from '@/components/field.vue'
import FieldGroup from '@/components/field-group.vue'
import FormDialog from '@/components/form-dialog.vue'
import Label from '@/components/label.vue'
import Textarea from '@/components/textarea.vue'
import { computed, reactive, ref, watch } from 'vue'

const maxLength = 2000
const open = ref(false)
const form = reactive({ content: '', pinned: false })
const notes = ref<{ content: string; pinned: boolean }[]>([])

watch(open, (value) => {
  if (value) {
    form.content = ''
    form.pinned = false
  }
})

const remaining = computed(() => maxLength - form.content.length)

function addNote() {
  const note = { content: form.content.trim(), pinned: form.pinned }
  notes.value = note.pinned ? [note, ...notes.value] : [...notes.value, note]
  open.value = false
}
</script>

<template>
  <Button @click="open = true">Add note</Button>
  <ul role="list">
    <li v-for="(note, index) in notes" :key="index">{{ note.content }}</li>
  </ul>
  <FormDialog
    :open="open"
    title="Add note"
    description="Notes stay internal. The customer never sees them."
    submit-label="Add note"
    :submit-disabled="form.content.trim() === ''"
    @close="open = false"
    @submit="addNote"
  >
    <FieldGroup>
      <Field>
        <Label>Note</Label>
        <Textarea
          v-model="form.content"
          name="content"
          rows="5"
          :maxlength="maxLength"
          placeholder="What should the team know about this customer?"
        />
        <Description v-if="remaining <= 200">{{ remaining }} characters left.</Description>
      </Field>
      <CheckboxField>
        <Checkbox v-model="form.pinned" name="is_pinned" />
        <Label>Pin this note</Label>
        <Description>Pinned notes stay at the top of the list.</Description>
      </CheckboxField>
    </FieldGroup>
  </FormDialog>
</template>

Two-column form

Use size="xl" and a grid gap-6 sm:grid-cols-2 inside the FieldGroup for address-like forms:

<FormDialog size="xl" title="Add address" submitLabel="Add address" submitDisabled={!canSubmit} …>
  <FieldGroup>
    <Field>
      <Label>Address line 1</Label>
      <Input name="line1" autoComplete="address-line1" placeholder="123 Main Street" />
    </Field>
    <Field>
      <Label>Address line 2</Label>
      <Input name="line2" autoComplete="address-line2" />
    </Field>
    <div className="grid gap-6 sm:grid-cols-2">
      <Field>
        <Label>City</Label>
        <Input name="city" />
      </Field>
      <Field>
        <Label>State or region</Label>
        <Input name="state" />
      </Field>
      <Field>
        <Label>Postal code</Label>
        <Input name="postal_code" />
      </Field>
      <Field>
        <Label>Country</Label>
        <Input name="country" />
      </Field>
    </div>
  </FieldGroup>
</FormDialog>
<template>
  <FormDialog size="xl" title="Add address" submit-label="Add address" :submit-disabled="!canSubmit" …>
    <FieldGroup>
      <Field>
        <Label>Address line 1</Label>
        <Input v-model="form.line1" name="line1" autocomplete="address-line1" placeholder="123 Main Street" />
      </Field>
      <Field>
        <Label>Address line 2</Label>
        <Input v-model="form.line2" name="line2" autocomplete="address-line2" />
      </Field>
      <div class="grid gap-6 sm:grid-cols-2">
        <Field>
          <Label>City</Label>
          <Input v-model="form.city" name="city" />
        </Field>
        <Field>
          <Label>State or region</Label>
          <Input v-model="form.state" name="state" />
        </Field>
        <Field>
          <Label>Postal code</Label>
          <Input v-model="form.postalCode" name="postal_code" />
        </Field>
        <Field>
          <Label>Country</Label>
          <Input v-model="form.country" name="country" />
        </Field>
      </div>
    </FieldGroup>
  </FormDialog>
</template>

With a badge and a notice

The badge slot sits opposite the title, for a step counter. Each submit moves to the next step and swaps the fields and the submit label:

<FormDialog
  size="xl"
  title="Change email address"
  description="Enter the address you want to use for signing in and notifications."
  badge={<Badge color="zinc" className="shrink-0">Step {step} of 3</Badge>}
  submitLabel={step === 1 ? 'Send verification code' : step === 2 ? 'Continue' : 'Change email'}
  onSubmit={() => (step < 3 ? setStep(step + 1) : setOpen(false))}
  …
>
  {step === 1 ? (
    <>
      <Notice icon={EnvelopeIcon}>
        For security, we will send verification codes to both your current and new addresses.
      </Notice>
      <Field className="mt-6">
        <Label>New email address</Label>
        <Input type="email" name="new_email" autoComplete="email" required />
      </Field>
    </>
  ) : (
    <Field key={step}>
      <Label>{step === 2 ? 'Code sent to your current address' : 'Code sent to your new address'}</Label>
      <CodeInput value={code} onChange={setCode} className="mt-3" />
    </Field>
  )}
</FormDialog>
<template>
  <FormDialog
    size="xl"
    title="Change email address"
    description="Enter the address you want to use for signing in and notifications."
    :submit-label="step === 1 ? 'Send verification code' : step === 2 ? 'Continue' : 'Change email'"
    @submit="step < 3 ? step++ : (open = false)"
    …
  >
    <template #badge>
      <Badge color="zinc" class="shrink-0">Step {{ step }} of 3</Badge>
    </template>
    <template v-if="step === 1">
      <Notice :icon="EnvelopeIcon">
        For security, we will send verification codes to both your current and new addresses.
      </Notice>
      <Field class="mt-6">
        <Label>New email address</Label>
        <Input type="email" name="new_email" autocomplete="email" required />
      </Field>
    </template>
    <Field v-else :key="step">
      <Label>{{ step === 2 ? 'Code sent to your current address' : 'Code sent to your new address' }}</Label>
      <CodeInput v-model="code" class="mt-3" />
    </Field>
  </FormDialog>
</template>

Destructive form

A confirmation that asks for a reason is a form, not an alert. Keep the question as the title and turn the submit button red:

<FormDialog
  color="red"
  title="Block Mariam Keita?"
  description="Payments and payment links stop working for this customer right away. Existing transactions are untouched, and you can unblock them at any time."
  submitLabel="Block customer"
  …
>
  <Field>
    <Label>Reason</Label>
    <Textarea name="reason" rows={3} maxLength={500} placeholder="Chargeback pattern, fraud alert, customer request…" />
    <Description>Optional, but it is what your team reads on the blocked customer.</Description>
  </Field>
</FormDialog>
<template>
  <FormDialog
    color="red"
    title="Block Mariam Keita?"
    description="Payments and payment links stop working for this customer right away. Existing transactions are untouched, and you can unblock them at any time."
    submit-label="Block customer"
    …
  >
    <Field>
      <Label>Reason</Label>
      <Textarea name="reason" rows="3" :maxlength="500" placeholder="Chargeback pattern, fraud alert, customer request…" />
      <Description>Optional, but it is what your team reads on the blocked customer.</Description>
    </Field>
  </FormDialog>
</template>

Password guard

For a step-up confirmation, the submit stays disabled until the password is typed:

<FormDialog
  size="md"
  color="red"
  title="Sign out other sessions?"
  description="Enter your password to confirm you want to sign out of all your other sessions. This device stays signed in."
  submitLabel="Sign out other sessions"
  submitDisabled={!password}
  …
>
  <Field>
    <Label>Password</Label>
    <Input type="password" name="password" autoComplete="current-password" />
  </Field>
</FormDialog>
<template>
  <FormDialog
    size="md"
    color="red"
    title="Sign out other sessions?"
    description="Enter your password to confirm you want to sign out of all your other sessions. This device stays signed in."
    submit-label="Sign out other sessions"
    :submit-disabled="!password"
    …
  >
    <Field>
      <Label>Password</Label>
      <Input v-model="password" type="password" name="password" autocomplete="current-password" />
    </Field>
  </FormDialog>
</template>