Skip to content
  1. Activity
  2. Notes

Notes

Internal notes on a record: the list, newest first with who wrote what and when, and the dialog that adds one. The customer never sees them, say so in the dialog.

  • Asked for invoices in the company name from now on.

    Awa Diallo · May 9, 2026

    Pinned
  • Refund of the duplicate charge approved by phone.

    Kofi Mensah · May 2, 2026

  • Prefers to be contacted by email, never before 10am.

    Awa Diallo · April 18, 2026

import { Button } from '@/components/button'
import { NoteDialog, NoteList } from '@/components/notes'
import { PlusIcon } from '@heroicons/react/16/solid'
import { useState } from 'react'

export default function Example({ notes, onAdd }) {
  let [open, setOpen] = useState(false)

  return (
    <div>
      <NoteList notes={notes} />
      <Button outline className={notes.length > 0 ? 'mt-4' : 'mt-3'} onClick={() => setOpen(true)}>
        <PlusIcon data-slot="icon" />
        Add note
      </Button>
      <NoteDialog open={open} onClose={() => setOpen(false)} onSubmit={onAdd} />
    </div>
  )
}
<script setup lang="ts">
import Button from '@/components/button.vue'
import NoteDialog from '@/components/note-dialog.vue'
import NoteList, { type Note } from '@/components/note-list.vue'
import { PlusIcon } from '@heroicons/vue/16/solid'
import { ref } from 'vue'

defineProps<{
  notes: Note[]
  add: (note: { content: string; pinned: boolean }) => Promise<void>
}>()

const open = ref(false)
</script>

<template>
  <div>
    <NoteList :notes="notes" />
    <Button outline :class="notes.length > 0 ? 'mt-4' : 'mt-3'" @click="open = true">
      <PlusIcon data-slot="icon" />
      Add note
    </Button>
    <NoteDialog v-model:open="open" :submit="add" />
  </div>
</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.

'use client'

import clsx from 'clsx'
import type React from 'react'
import { useEffect, useState } from 'react'
import { Badge } from './badge'
import { Checkbox, CheckboxField } from './checkbox'
import { Description, Field, FieldGroup, Label } from './fieldset'
import { FormDialog } from './form-dialog'
import { Textarea } from './textarea'

export type Note = {
  id: string
  body: string
  author: string
  date: string
  pinned?: boolean
}

export function NoteList({
  notes,
  emptyLabel = 'No notes yet.',
  className,
  ...props
}: { notes: Note[]; emptyLabel?: string } & React.ComponentPropsWithoutRef<'ul'>) {
  if (notes.length === 0) {
    return (
      <p
        {...(props as React.ComponentPropsWithoutRef<'p'>)}
        className={clsx(className, 'text-base/6 sm:text-sm/6 text-zinc-500 dark:text-zinc-400')}
      >
        {emptyLabel}
      </p>
    )
  }

  let sorted = [...notes].sort((a, b) => Number(Boolean(b.pinned)) - Number(Boolean(a.pinned)))

  return (
    <ul role="list" {...props} className={clsx(className, 'divide-y divide-zinc-950/5 dark:divide-white/5')}>
      {sorted.map((note) => (
        <li key={note.id} className="py-4 first:pt-0 last:pb-0">
          <p className="text-base/6 text-zinc-950 text-pretty sm:text-sm/6 dark:text-white">{note.body}</p>
          <div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1">
            <p className="text-base/6 sm:text-sm/6 text-zinc-500 dark:text-zinc-400">
              {note.author} · {note.date}
            </p>
            {note.pinned && <Badge color="amber">Pinned</Badge>}
          </div>
        </li>
      ))}
    </ul>
  )
}

export function NoteDialog({
  open,
  onClose,
  onSubmit,
  title = 'Add note',
  description = 'Notes stay internal. Only your team sees them.',
  placeholder = 'What should the team know?',
  maxLength = 2000,
  submitLabel = 'Add note',
}: {
  open: boolean
  onClose: () => void
  onSubmit: (note: { content: string; pinned: boolean }) => void | Promise<void>
  title?: React.ReactNode
  description?: React.ReactNode
  placeholder?: string
  maxLength?: number
  submitLabel?: React.ReactNode
}) {
  let [content, setContent] = useState('')
  let [pinned, setPinned] = useState(false)
  let [pending, setPending] = useState(false)

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

  let remaining = maxLength - content.length

  return (
    <FormDialog
      open={open}
      onClose={onClose}
      onSubmit={async () => {
        setPending(true)
        try {
          await onSubmit({ content, pinned })
          onClose()
        } finally {
          setPending(false)
        }
      }}
      size="lg"
      title={title}
      description={description}
      submitLabel={submitLabel}
      submitDisabled={pending || content.trim() === ''}
    >
      <FieldGroup>
        <Field>
          <Label>Note</Label>
          <Textarea
            value={content}
            name="content"
            rows={5}
            maxLength={maxLength}
            placeholder={placeholder}
            onChange={(event) => setContent(event.target.value)}
          />
          <Description aria-live="polite" className={remaining > 200 ? 'sr-only' : undefined}>
            {remaining <= 200 ? `${remaining} characters left.` : ''}
          </Description>
        </Field>
        <CheckboxField>
          <Checkbox checked={pinned} onChange={setPinned} name="is_pinned" />
          <Label>Pin this note</Label>
          <Description>Pinned notes stay at the top of the list.</Description>
        </CheckboxField>
      </FieldGroup>
    </FormDialog>
  )
}
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import Checkbox from './checkbox.vue'
import CheckboxField from './checkbox-field.vue'
import Description from './description.vue'
import Field from './field.vue'
import FieldGroup from './field-group.vue'
import FormDialog from './form-dialog.vue'
import Label from './label.vue'
import Textarea from './textarea.vue'

const props = withDefaults(
  defineProps<{
    title?: string
    description?: string
    placeholder?: string
    maxLength?: number
    submitLabel?: string
    submit?: (note: { content: string; pinned: boolean }) => void | Promise<void>
  }>(),
  {
    title: 'Add note',
    description: 'Notes stay internal. Only your team sees them.',
    placeholder: 'What should the team know?',
    maxLength: 2000,
    submitLabel: 'Add note',
  }
)

const open = defineModel<boolean>('open', { default: false })

const emit = defineEmits<{ submit: [note: { content: string; pinned: boolean }] }>()

const form = reactive({ content: '', pinned: false })
const pending = ref(false)

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

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

async function submit() {
  const note = { content: form.content, pinned: form.pinned }
  emit('submit', note)
  pending.value = true
  try {
    await props.submit?.(note)
    open.value = false
  } finally {
    pending.value = false
  }
}
</script>

<template>
  <FormDialog
    :open="open"
    size="lg"
    :title="title"
    :description="description"
    :submit-label="submitLabel"
    :submit-disabled="pending || form.content.trim() === ''"
    @close="open = false"
    @submit="submit"
  >
    <FieldGroup>
      <Field>
        <Label>Note</Label>
        <Textarea v-model="form.content" name="content" rows="5" :maxlength="maxLength" :placeholder="placeholder" />
        <Description aria-live="polite" :class="remaining > 200 ? 'sr-only' : undefined">
          {{ 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>

Component API

PropDefaultDescription
NoteListrenders a <ul> element
notes-Array of `{ id, body, author, date, pinned? }`, in the order to show. Pinned notes move to the top. `date` is already formatted.
emptyLabel'No notes yet.'The line shown when the array is empty.
NoteDialogbuilt on the <FormDialog> component
open-Whether the dialog is open. The fields reset each time it opens.
onClose-Called by Cancel, Escape and the backdrop.
onSubmit-Called with `{ content, pinned }`. Return a promise and the dialog waits for it: the submit button stays disabled meanwhile, and a rejection keeps the dialog open with the text.
title'Add note'Title of the dialog.
description'Notes stay internal. Only your team sees them.'Line under the title.
placeholder'What should the team know?'Placeholder of the textarea.
maxLength2000Cap on the note. The counter appears in the last 200 characters.
submitLabel'Add note'Label of the submit button.
PropDefaultDescription
NoteListrenders a <ul> element
notes-Array of `{ id, body, author, date, pinned? }`, in the order to show. Pinned notes move to the top. `date` is already formatted.
emptyLabel'No notes yet.'The line shown when the array is empty.
NoteDialogbuilt on the <FormDialog> component
v-model:open-Whether the dialog is open. The fields reset each time it opens.
submit-Called with `{ content, pinned }`. Return a promise and the dialog waits for it: the submit button stays disabled meanwhile, and a rejection keeps the dialog open with the text.
@submit-Emitted with `{ content, pinned }` when the form is sent, before `submit` runs.
title'Add note'Title of the dialog.
description'Notes stay internal. Only your team sees them.'Line under the title.
placeholder'What should the team know?'Placeholder of the textarea.
maxLength2000Cap on the note. The counter appears in the last 200 characters.
submitLabel'Add note'Label of the submit button.

Examples

Basic example

Pass the notes already sorted and dated, pinned ones first. The dialog gates its button until there is text, and shows the counter only near the cap:

  • Asked for invoices in the company name from now on.

    Awa Diallo · May 9, 2026

    Pinned
  • Refund of the duplicate charge approved by phone.

    Kofi Mensah · May 2, 2026

  • Prefers to be contacted by email, never before 10am.

    Awa Diallo · April 18, 2026

import { Button } from '@/components/button'
import { NoteDialog, NoteList } from '@/components/notes'
import { PlusIcon } from '@heroicons/react/16/solid'
import { useState } from 'react'

export default function Example({ notes, onAdd }) {
  let [open, setOpen] = useState(false)

  return (
    <div>
      <NoteList notes={notes} />
      <Button outline className={notes.length > 0 ? 'mt-4' : 'mt-3'} onClick={() => setOpen(true)}>
        <PlusIcon data-slot="icon" />
        Add note
      </Button>
      <NoteDialog open={open} onClose={() => setOpen(false)} onSubmit={onAdd} />
    </div>
  )
}
<script setup lang="ts">
import Button from '@/components/button.vue'
import NoteDialog from '@/components/note-dialog.vue'
import NoteList, { type Note } from '@/components/note-list.vue'
import { PlusIcon } from '@heroicons/vue/16/solid'
import { ref } from 'vue'

defineProps<{
  notes: Note[]
  add: (note: { content: string; pinned: boolean }) => Promise<void>
}>()

const open = ref(false)
</script>

<template>
  <div>
    <NoteList :notes="notes" />
    <Button outline :class="notes.length > 0 ? 'mt-4' : 'mt-3'" @click="open = true">
      <PlusIcon data-slot="icon" />
      Add note
    </Button>
    <NoteDialog v-model:open="open" :submit="add" />
  </div>
</template>

Empty list

With no notes the list is one line, and the button sits a little closer. Word the placeholder for the record at hand:

No notes yet.

<NoteList notes={[]} />
<NoteDialog
  open={open}
  onClose={() => setOpen(false)}
  onSubmit={onAdd}
  placeholder="What should the team know about this transaction?"
/>
<NoteList :notes="[]" />
<NoteDialog v-model:open="open" placeholder="What should the team know about this transaction?" :submit="add" />