---
title: "LogSheet"
description: "A small sheet that slides up so you can record something in a few seconds without leaving the screen you were on. One reading, one dose, one note."
url: "https://opsinjs.pensievelabs.org/components/log-sheet"
source: "https://opsinjs.pensievelabs.org/components/log-sheet.md"
section: "Components"
status: "shipped"
kind: "component"
category: "health-input"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["log", "capture", "quick entry", "bottom sheet entry", "diary", "journal", "daily entry"]
governedBy: ["numbers-units-precision", "unit-systems", "consent-and-disclosure"]
usedIn: ["ask-users-for/height-and-weight", "ask-users-for/medications", "ask-users-for/symptoms", "daily-log-entry", "daily-log-screen", "daily-logging", "diabetes-medicines-app", "forms/autocomplete-and-input-types", "forms/question-pages", "forms/required-and-optional", "forms/units-and-numeric-entry", "forms/validation-timing"]
implemented: true
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> `<StubNotice>` IS THE EXCEPTION, AND IT IS THE ONE TO READ. It is a
> paired element rather than a self-closing one, and the text between
> its opening and closing tags is prose an author wrote, reproduced
> below word for word. That prose is where this page says whether the
> component has been reviewed. Read the children, not only the
> attributes.

<StubNotice
  name="log-sheet"
  status="shipped"
  questions="[
  &#x22;The field ceiling is unenforced because React.Children sees direct children only, so is the answer a lint rule, a fields prop, or a rule people follow?&#x22;,
  &#x22;The sheet cannot tell a duplicate reading from a real second measurement minutes later, so should the entry carry something that lets the product decide?&#x22;,
  &#x22;Daily logging names six entry states, LogSheet expresses only draft through onDiscard, and where saving, queued, conflict, failed and first use belong is open.&#x22;,
]"
>
  Audited against WCAG 2.2 AA. Clinical review pending.
</StubNotice>

## Preview [#preview]

<ComponentPreview name="log-sheet" />

## Installation [#installation]

<ComponentInstall name="log-sheet" unbuilt="false" importPath="@/components/ui/log-sheet" registryDependencies="[&#x22;sheet&#x22;, &#x22;field&#x22;, &#x22;button&#x22;]" />

## Usage [#usage]

```tsx
import { LogSheet } from "@/components/ui/log-sheet"
```

```tsx
<LogSheet
  open={open}
  onOpenChange={setOpen}
  title="An example entry"
  values={values}
  saveLabel="Save the example entry"
  onSave={(entry) => store(entry)}
>
  <Field label="Example measurement">
    <Field.Control value={held ?? ""} onChange={write} />
  </Field>
</LogSheet>
```

## When to use it [#when-to-use-it]

<WhenToUse
  use="[
  &#x22;Recording one thing the reader does repeatedly: a reading, a dose, a symptom or a note.&#x22;,
  &#x22;Capture that must not lose the reader's place, from a dashboard or a notification.&#x22;,
  &#x22;An entry that should take under fifteen seconds one-handed.&#x22;,
]"
  avoid="[
  { case: &#x22;The entry needs more than about five fields, or branching questions.&#x22;, instead: &#x22;field&#x22; },
  { case: &#x22;The questions form an assessment rather than a record.&#x22;, instead: &#x22;questionnaire&#x22; },
  { case: &#x22;You need permission before you can store what is being logged.&#x22;, instead: &#x22;consent-sheet&#x22; },
  { case: &#x22;There is exactly one numeric value with a unit and nothing else.&#x22;, instead: &#x22;reading-input&#x22; },
  { case: &#x22;You are showing what was logged rather than capturing it.&#x22;, instead: &#x22;result-card&#x22; },
]"
/>

## Clinical meaning [#clinical-meaning]

**Asserts.** That the reader recorded this value, at this time, themselves.
`LogEntry.provenance` is the literal `"self-reported"` and no prop changes it. See
[Data provenance and device accuracy](../health/data-provenance-and-device-accuracy.mdx).

**Never read as.** A clinical observation or an assessment. The sheet never evaluates
what is entered or says whether it is worth acting on.

**Colour axis.** Category only, on one band beneath the heading carrying `data-category`
with no word and no glyph. The status axis never appears here: an amber field
mid-keystroke teaches a person to stop logging honestly.

**Thresholds.** None. LogSheet holds no ranges and never blocks a save, so an unlikely
value still saves. A plausibility warning belongs on [ReadingInput](./reading-input.mdx).
`maxBackdateDays` has no default and constrains the platform picker, not the save.

**Timing.** `occurredAt` is the time the entry is about and `recordedAt` the save time.
`backdated` is true only when the reader moved the time control.

## Anatomy [#anatomy]

<Anatomy
  name="log-sheet"
  parts="[
  {
    name: &#x22;LogSheet&#x22;,
    describes: &#x22;The capture region inside Sheet's scrolling area. Header, title, close control and footer are Sheet's.&#x22;,
  },
  {
    name: &#x22;LogSheet.Category&#x22;,
    describes: &#x22;One aria-hidden tinted band carrying data-category. Rendered only when category is supplied.&#x22;,
    prop: &#x22;category&#x22;,
  },
  {
    name: &#x22;LogSheet.Fields&#x22;,
    describes: &#x22;The product's own controls, rendered in order and never inspected.&#x22;,
    prop: &#x22;children&#x22;,
  },
  {
    name: &#x22;LogSheet.Time&#x22;,
    describes: &#x22;A Field wrapping a datetime-local control, defaulted to when the sheet opened.&#x22;,
    prop: &#x22;timeLabel&#x22;,
  },
  {
    name: &#x22;LogSheet.Note&#x22;,
    describes: &#x22;A Field wrapping an optional textarea. Rendered only when noteLabel is supplied.&#x22;,
    prop: &#x22;noteLabel&#x22;,
  },
  {
    name: &#x22;LogSheet.Save&#x22;,
    describes: &#x22;The one primary action, full width in Sheet's footer. No default label.&#x22;,
    prop: &#x22;saveLabel&#x22;,
  },
  {
    name: &#x22;LogSheet.Confirm&#x22;,
    describes: &#x22;Replaces the save action when a dismissal arrives over unsaved input.&#x22;,
  },
  {
    name: &#x22;LogSheet.Answers&#x22;,
    describes: &#x22;The two answers stacked vertically with 16px between them.&#x22;,
  },
  {
    name: &#x22;LogSheet.Keep&#x22;,
    describes: &#x22;The safe answer, where the save action was, and the one focus lands on.&#x22;,
  },
  {
    name: &#x22;LogSheet.Discard&#x22;,
    describes: &#x22;The destructive answer, first in tab order. Calls onDiscard with the entry, then closes.&#x22;,
    prop: &#x22;onDiscard&#x22;,
  },
]"
/>

## Examples [#examples]

### Leaving with unsaved input [#leaving-with-unsaved-input]

Escape, the background, a drag and the close control all ask the same question in the
footer. `onDiscard` receives the entry about to be lost.

<ComponentPreview name="log-sheet-leaving-with-unsaved-input" kind="example" align="start" />

### Two fields, and the window the product owns [#two-fields-and-the-window-the-product-owns]

Two controls write two keys into one entry through `values`. `maxBackdateDays` sets the
picker's earliest day, and the sheet still refuses no save.

<ComponentPreview name="log-sheet-two-fields-and-a-backdate-window" kind="example" align="start" />

## Content guidelines [#content-guidelines]

The title names what is being logged: *Blood pressure*, *How you slept*. `saveLabel`
says what it saves, *Save reading*, never *Done*, so it has no default. Ask for nothing
you can infer; the fourth field stops people logging.

<DoDont>
  <DoDont.Do>
    **"Systolic, Diastolic, Time (now), Save reading."** Three inputs and an action
    that says what it does.
  </DoDont.Do>

  <DoDont.Dont>
    **"Systolic, Diastolic, Pulse, Arm, Position, Cuff size, Mood, Notes, Submit."**
    Defensible fields, unusable set.
  </DoDont.Dont>
</DoDont>

## Accessibility [#accessibility]

Audited against WCAG 2.2 AA in a source pass and a rendered pass. This audit is
author-run, not an independent review, and clinical review is still pending.

The audit found nothing to change here. Both inputs take their name and description
from [Field](./field.mdx), so name, role and value hold; the only coloured element is
the aria-hidden category band; and the single tokenised column reflows and scales.

Three items are argued, not measured.

* The discard question is announced by a focus move and `aria-describedby`, not a live
  region; a second modal was declined on purpose.
* No *Saved* status is announced: `onSave` never closes the sheet, so the component cannot
  know the save landed and that feedback belongs to the product.
* Save is one explicit tap with no confirm step: no autosave, every field visible, sheet
  stays open for undo.

The open sheet was not re-measured in a browser: port 4000 holds a stale build.
`axe` on the static rendered page is clean.

<KeyboardTable
  name="log-sheet"
  rows="[
  {
    keys: &#x22;Tab&#x22;,
    action: &#x22;Moves through the sheet's parts in visual order&#x22;,
    notes: &#x22;Close control, fields, time, note, action. A datetime-local can cost several presses.&#x22;,
  },
  {
    keys: &#x22;Shift + Tab&#x22;,
    action: &#x22;The same sequence, backwards&#x22;,
    notes: &#x22;Sheet's close control is first forwards, last backwards.&#x22;,
  },
  {
    keys: &#x22;Escape&#x22;,
    action: &#x22;Asks before discarding when there is unsaved input; closes when there is not&#x22;,
    notes: &#x22;Same as a background tap. With the question up, it answers Keep editing.&#x22;,
  },
  {
    keys: &#x22;Enter&#x22;,
    action: &#x22;Activates the focused control&#x22;,
    notes: &#x22;Never saves from a field. No form element, no implicit submit.&#x22;,
  },
  {
    keys: &#x22;Space&#x22;,
    action: &#x22;Activates the focused button&#x22;,
    notes: &#x22;Real buttons, so the sheet does not scroll.&#x22;,
  },
]"
/>

<ContrastReport component="log-sheet" />

## Data attributes [#data-attributes]

<DataAttributesTable
  name="log-sheet"
  rows="[
  {
    attribute: &#x22;data-slot&#x22;,
    condition: &#x22;On the capture region, fields wrapper and time field, always&#x22;,
    value: &#x22;log-sheet, log-sheet-fields, log-sheet-time&#x22;,
  },
  {
    attribute: &#x22;data-slot&#x22;,
    condition: &#x22;On the note, category band and footer parts, only while rendered&#x22;,
    value: &#x22;log-sheet-note, log-sheet-category, log-sheet-save, log-sheet-confirm, log-sheet-answers, log-sheet-discard, log-sheet-keep&#x22;,
  },
  {
    attribute: &#x22;data-category&#x22;,
    condition: &#x22;On log-sheet-category, when a recognised category is supplied&#x22;,
    value: &#x22;sleep | heart | activity | nutrition | mind | labs&#x22;,
  },
]"
/>

## API reference [#api-reference]

<PropsTable name="LogSheetProps" />

`open`, `onOpenChange`, `title`, `detents` and `className` come from
[Sheet](./sheet.mdx). `footer`, `modal` and `dismissible` are omitted on purpose.
`onSave` never closes the sheet; only the product knows whether the save landed. Both
handlers receive a `LogEntry`:

```tsx
export interface LogEntry {
  values: Record<string, number | string | null>
  occurredAt: string
  recordedAt: string
  backdated: boolean
  provenance: "self-reported"
  note?: string
}
```

## Related [#related]

* [Sheet](./sheet.mdx) is the general surface. LogSheet adds a capture contract and one save.
* [ReadingInput](./reading-input.mdx) is the single control that goes inside.
* [Questionnaire](./questionnaire.mdx) is an assessment, not a record.
* [Field](./field.mdx) is for entries that belong on a page, and wires every control here.
* [ConsentSheet](./consent-sheet.mdx) asks for permission rather than a record.
