---
title: "Quick start"
description: "Build a result card that shows one value, its reference range and its clinical status, from three components that are implemented and installable."
url: "https://opsinjs.pensievelabs.org/start/quick-start"
source: "https://opsinjs.pensievelabs.org/start/quick-start.md"
section: "Start here"
kind: "guide"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["quick start", "first result card", "hello world"]
---

> 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.
> Nothing is missing from this page. The data simply does not live in
> the prose.

<PageTemplate kind="guide" />

## Overview [#overview]

The smallest complete opsinjs screen is one value, the range it should be read
against, and a sentence saying whether the person needs to do anything. That is
what `ResultCard` is for, and building it exercises almost every rule the system
has: the two colour axes, the number formatting contract, the reference-range
rule, and the accessibility floor.

<Callout title="Shipped components, and no published host yet">
  All three components below are implemented, and their registry items carry the
  full source that `shadcn add` copies. Two things are still absent. There is no
  npm package and there will not be one. Distribution is registry copy-in, and
  [ADR 0002](../project/decisions/0002-shadcn-registry-distribution.mdx) is why.
  `opsinjs.pensievelabs.org`, the canonical host every command on this site
  names, is also not serving yet, so `@opsinjs` resolves only against a registry
  you point it at yourself. All three are `shipped`, so the code installs and the
  API may change in any release. None of them has been reviewed, which makes this
  fit for a prototype rather than for use in front of a patient. The `ResultCard` block
  further down is older than the implementation and still uses the names the
  specification proposed; the marked gap under it says which.
</Callout>

You will need a React 19 project with Tailwind v4 already working. If you do not
have one, [Installation](./installation/index.mdx) covers each framework.

## Add the pieces [#add-the-pieces]

Three registry items: the card itself, the range bar it composes, and the status
pill that carries the clinical level. The command is a plain `shadcn` add against
the `@opsinjs` namespace. opsinjs does not ship its own CLI, because the shadcn
CLI already does this job. `result-card` names the other two as registry
dependencies, so adding it alone brings them, along with the two `lib` modules
every opsinjs component imports.

<CodeBlockTabs defaultValue="npm" groupId="package-manager">
  <CodeBlockTabsList>
    <CodeBlockTabsTrigger value="npm">
      npm
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="pnpm">
      pnpm
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="yarn">
      yarn
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="bun">
      bun
    </CodeBlockTabsTrigger>
  </CodeBlockTabsList>

  <CodeBlockTab value="npm">
    ```bash
    npx shadcn@latest add @opsinjs/result-card @opsinjs/range-bar @opsinjs/status-pill
    ```
  </CodeBlockTab>

  <CodeBlockTab value="pnpm">
    ```bash
    pnpm dlx shadcn@latest add @opsinjs/result-card @opsinjs/range-bar @opsinjs/status-pill
    ```
  </CodeBlockTab>

  <CodeBlockTab value="yarn">
    ```bash
    yarn dlx shadcn@latest add @opsinjs/result-card @opsinjs/range-bar @opsinjs/status-pill
    ```
  </CodeBlockTab>

  <CodeBlockTab value="bun">
    ```bash
    bun x shadcn@latest add @opsinjs/result-card @opsinjs/range-bar @opsinjs/status-pill
    ```
  </CodeBlockTab>
</CodeBlockTabs>

That copies real source files into your project rather than adding a dependency.
You own the result. See [Registry](../registry/index.mdx) for what the namespace
resolves to and [Upgrading](./installation/upgrading.mdx) for how you take changes
to code you own.

## Decide what the number means before you render it [#decide-what-the-number-means-before-you-render-it]

This is the step that a general-purpose UI library does not have, and it is the
one that determines whether your screen is honest.

<Steps>
  ### Pick the reference range from a source you can name [#pick-the-reference-range-from-a-source-you-can-name]

  Not from opsinjs, and not from a component default. The range depends on the
  assay, the laboratory, the population and often the person. Your product's
  clinical owner supplies it, and your UI should be able to say where it came from.
  [Reference ranges](../health/reference-ranges.mdx) covers what happens when there
  is no defensible range: you show the value without one.

  ### Map the value to a clinical status, in your code [#map-the-value-to-a-clinical-status-in-your-code]

  The four status levels are a fixed vocabulary with fixed meanings, and your
  product assigns them. A component never derives a status from a number.
  [Clinical status semantics](../health/clinical-status-semantics.mdx) defines each
  level, who may assign it, and what it must never be read as.

  ### Choose the category, independently [#choose-the-category-independently]

  The category says which part of a person's health this is. It never says how
  concerning the reading is. Both attributes travel on the component and are
  rendered on different visual channels, which is what keeps them apart in the
  markup. There is no build-time rule refusing a category colour used to signal
  severity. The [lint plugins](../handbook/tooling/index.mdx) that would do it are
  specified and not built, so this is a review check today. See
  [The two colour axes](../health/two-colour-axes.mdx).

  ### Write the sentence [#write-the-sentence]

  A number without a sentence makes the reader guess, and anxious readers guess
  badly. [Writing status and alerts](../content/writing-status-and-alerts.mdx) gives
  the sentence pattern for each status level and the words banned at each. That
  list starts with *normal*, which is banned everywhere.
</Steps>

## Render it [#render-it]

The shape of the screen. Read the props off
[ResultCard](../components/result-card.mdx) before you copy this block. It was
written against the proposal and has not been brought back into line with the
component that now exists:

```tsx title="app/results/page.tsx"
import { ResultCard } from "@/components/opsinjs/result-card"

export default function Page() {
  return (
    <ResultCard
      label="Blood pressure"
      category="heart"
      status="watch"
      value={{ systolic: 138, diastolic: 86 }}
      unit="mmHg"
      precision={0}
      range={{ low: 90, high: 120, source: "Your clinic, 2026" }}
      measuredAt="2026-09-01T08:12:00Z"
    >
      Slightly higher than your usual morning reading. One reading on its own
      does not mean much. Take another tomorrow at the same time.
    </ResultCard>
  )
}
```

<Todo owner="engineering">
  Reconcile this example with the shipped props on
  [ResultCard](../components/result-card.mdx). The card takes `title` rather than
  `label`, a compound reading such as a blood pressure through `segments` rather
  than an object in `value`, the explanation through `meaning` rather than as
  children, and a required `now` read once per screen so a page of cards cannot
  disagree with itself across a minute boundary. A `range` is drawn only against
  a single reading, so the corrected example has to choose between the pair and
  the bar rather than showing both.
</Todo>

Three things in that block are load-bearing, and all three survive the rename
described above. The shipped component requires them under its own prop names,
so they are enforced by the type checker rather than suggested by this page:

* **`status` and `category` are separate props.** They cannot be collapsed into
  one "variant", because they answer different questions.
* **`range` carries its `source`.** A range with no attributable source is a claim
  the interface cannot support, and the component surfaces the source rather than
  hiding it.
* **`measuredAt` is required, not optional.** A health value with no timestamp is
  ambiguous in a way that matters: the reader cannot tell a reading from this
  morning from one taken six weeks ago. See
  [Uncertainty, staleness and missing data](../health/uncertainty-and-staleness.mdx).

The full interface, its defaults and its prohibitions live on the component
page: [ResultCard](../components/result-card.mdx).

## Verify it worked [#verify-it-worked]

These are the checks that decide whether your screen is correct, and they are
worth running against a design before anything is built.

* **Switch the whole screen to greyscale.** Every status distinction must still be
  legible from the icon, the word and the position. If greyscale loses the
  meaning, colour was doing work it is not allowed to do alone.
* **Set the system text size to 200%.** Nothing may clip, truncate or scroll
  horizontally. The value stays the largest thing on the card.
* **Read the card aloud, in order, with your eyes shut.** The announcement order
  is label, value, unit, status, then time. If the status arrives before the
  value, a screen-reader user hears the alarm before they hear what it is about.
* **Count the urgent surfaces on the screen.** More than one is a defect. See
  [Alarm fatigue](../health/alarm-fatigue.mdx).
* **Search the rendered copy for the word *normal*.** It should not be there.

## Troubleshooting [#troubleshooting]

**The command fails to resolve.** `opsinjs.pensievelabs.org` is not serving yet, so a
`components.json` pointing `@opsinjs` there fails before the CLI reaches a
registry at all. See
[Troubleshooting](./troubleshooting.mdx#nothing-is-published-yet), which
separates that from the different failure of having no `registries` entry.

**You want the component to pick the status for you.** It will not, and this is
the single hardest constraint for teams arriving from a general-purpose library.
A component that derives severity from a number has made a clinical decision
inside a rendering layer, where it cannot be reviewed, versioned or audited.

**You have a value but no defensible range.** Render the value without a range.
An invented range is worse than no range: it looks authoritative and is not.

**Your category and your status want the same colour.** They are not allowed to
share one. If your brand's heart colour is a red, the status axis still owns red,
and [Category palettes](../theming/category-palettes.mdx) explains how to add a
category without contaminating the status axis.

## Next [#next]

* [Add your first component](./add-your-first-component.mdx) is the same thing in
  sixty seconds, with nothing else to read.
* [Choose a component](../recipes/choose-a-component.mdx) has the decision table
  from the shape of your data to the component that displays it.
* [ResultCard](../components/result-card.mdx) is the full specification,
  including the parts this page skipped.
