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
PinnedRefund 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>
)
}Also copy Badge, Checkbox, Fieldset, Form dialog, Textarea.
<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>Also copy Checkbox, Fieldset, Form dialog, Textarea, Badge.
Component API
| Prop | Default | Description |
|---|---|---|
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. |
maxLength | 2000 | Cap on the note. The counter appears in the last 200 characters. |
submitLabel | 'Add note' | Label of the submit button. |
| Prop | Default | Description |
|---|---|---|
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. |
maxLength | 2000 | Cap 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
PinnedRefund 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" />