# Writing Component Pages

> Use when adding or editing a component documentation page under components/ in py-shiny-site (inputs, outputs, display-messages, layout) — covers the directory layout, index.qmd front matter, app-*.py preview/example files, shinylive link generation, and sidebar registration.

- Skill: `posit-dev/writing-component-pages` (Agent Skill)
- Install (CLI): `npx skillmds@latest add posit-dev/writing-component-pages`
- Raw SKILL.md: https://api.skillmd.com/api/skills/posit-dev/writing-component-pages/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: posit-dev (https://skillmd.com/u/posit-dev)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/posit-dev/writing-component-pages

---


# Writing Component Pages

## Overview

A component page is a directory under `components/{inputs,outputs,display-messages,layout}/<name>/`
containing an `index.qmd` plus a set of `app-*.py` example files. The `index.qmd`
front matter is data-driven: EJS templates in `components/_partials/` turn it into
the rendered page (gallery card, live preview, tabbed Core/Express code, variations,
kitchen sink). Get the file names and front-matter keys right and the build does the rest.

**Reference implementations to copy from:**
- Minimal input: `components/inputs/action-button/` (example + kitchen sink)
- Rich output with variations: `components/outputs/data-grid/`

## Anatomy of a component directory

```
components/inputs/<name>/
  index.qmd                     # required — page content + front matter
  app-preview.py                # required — gallery-card thumbnail app (Core style)
  app-detail-preview.py         # the live "Preview" app shown atop the example
  app-core.py                   # Core-mode example (shown in "Core" tab)
  app-express.py                # Express-mode example (shown in "Express" tab)
  app-kitchensink-core.py       # optional — every-parameter demo (Core)
  app-kitchensink-express.py    # optional — every-parameter demo (Express)
  app-variation-<slug>-core.py     # optional — one set per variation
  app-variation-<slug>-express.py
  app-variation-<slug>-preview.py  # dedicated live Preview app for that variation
  thumbnail.png                 # optional — static image for the kitchen-sink block
```

**File-role cheat sheet:**

| File | Rendered as | Style |
|------|-------------|-------|
| `app-preview.py` | Gallery-list card (via `appPreview:` + `make docs-static-previews`) | Core, module-level `app_ui`, viewport-filling (`vh-100 d-flex justify-content-center align-items-center`) |
| `app-detail-preview.py` | Live standalone app at top of `#example` (the "Preview" tab) | Core, module-level `app_ui`; may use `## file: app.py` multi-file marker |
| `app-core.py` / `app-express.py` | Static code blocks in the Core/Express tabs, each with an "Open in editor" shinylive link | Idiomatic Core / Express; mark the key line with a trailing `# <<` comment |
| `app-variation-*-{core,express}.py` | One entry in the `#variations` block | Same as core/express |
| `app-kitchensink-*.py` | Linked from the `#kitchen-sink` block | All parameters exercised |

**Every example app file MUST be named `app.py` or `app-<name>.py`.** This is the only
naming the tooling recognizes: the shinylive-link generator, the static-preview renderer,
and the smoke-test collector (`components/test_examples_smoke.py`, which auto-discovers and
launches every `app.py`/`app-*.py` under `components/`) all key off this convention. A
differently named `.py` file is silently ignored — never rendered, never linked, never
tested. Companion files inside a multi-file app use `## file: <name>.py` markers *inside*
the `app-*.py`, not separate top-level names. See the `testing-example-apps` skill for how
those apps get smoke-tested.

**Why `app-preview.py` and `app-detail-preview.py` must be Core style:**
`components/make-static-previews.py` imports the module and reads `app_ui`. Express
apps have no module-level `app_ui`, so the static renderer raises `app_ui not found`.

## index.qmd front matter

Front matter is YAML between `---` fences. The body below it holds only the placeholder
blocks and prose. Structure (see `data-grid` for all of it):

```yaml
---
title: Task Button
sidebar: components
appPreview:
  file: components/inputs/task-button/app-preview.py   # repo-root-relative path
  static: true                                          # render to static HTML card
listing:
- id: example
  template: ../../_partials/components-detail-example.ejs
  template-params:
    dir: components/inputs/task-button/
  contents:
  - title: Preview          # rendered as a LIVE app; gets NO shinylive link
    file: app-detail-preview.py
    height: 200
  - title: Express          # static code + shinylive editor link
    file: app-express.py
    shinylive: PLACEHOLDER  # regenerated by `make docs-update-shinylive-links`
  - title: Core
    file: app-core.py
    shinylive: PLACEHOLDER
- id: relevant-functions
  template: ../../_partials/components-detail-relevant-functions.ejs
  contents:
  - title: ui.input_task_button
    href: https://shiny.posit.co/py/api/ui.input_task_button.html
    signature: ui.input_task_button(id, label, ...)
- id: variations           # optional
  template: ../../_partials/components-variations.ejs
  template-params:
    dir: components/inputs/task-button/
  contents:
  - title: Reset the button
    description: Call `ui.update_task_button()` to reset it.
    apps:
    - title: Preview                          # dedicated preview file, no shinylive key
      file: app-variation-reset-preview.py
      height: 350
    - title: Express
      file: app-variation-reset-express.py
      shinylive: PLACEHOLDER
    - title: Core
      file: app-variation-reset-core.py
      shinylive: PLACEHOLDER
- id: kitchen-sink         # optional
  template: ../../_partials/components-detail-kitchen-sink.ejs
  contents:
  - title: Task Button
    core: https://shinylive.io/py/editor/#...   # full editor URL, hand-added
    express: https://shinylive.io/py/editor/#...
    image: thumbnail.png
---

:::{#example}
:::

:::{#relevant-functions}
:::

## Details

Prose explaining what the component is and how to use it.

:::{#variations}
:::

:::{#kitchen-sink}
:::

See also: [Action Button](../action-button/index.qmd)
```

**Key rules:**
- `sidebar: components` is required on every page.
- Prefer `appPreview: { file, static: true }` (a rendered screenshot of `app-preview.py`)
  over the inline `preview:` HTML-string key. Only ~8 pages use `preview:`; reserve it
  for components that genuinely can't be statically rendered.
  **Exception: components whose card effect only appears on interaction** (toasts,
  popovers, notifications, modals — most of `display-messages/`) must omit
  `static: true` so the card runs LIVE via shinylive; a static snapshot of a
  "Show toast" button is a card whose button does nothing. Every existing
  display-messages card is live.
- The `#example` **Preview** tab (and each variation's Preview) runs LIVE via shinylive;
  the link generator deliberately skips `title: Preview`, so it needs no `shinylive:` key.
  Every non-Preview app **must** carry a `shinylive:` key or the generator warns and skips it.
- Point the Preview tab at a dedicated **`app-detail-preview.py`**, not at `app-core.py`
  or `app-preview.py`. (Reusing core/preview is the most common shortcut in review.)
- `relevant-functions` `href` and `signature` are **generated, not hand-written**. Add the
  entry with just a `title:` (e.g. `ui.input_text_area`) plus placeholder `href`/`signature`,
  then run `make docs-update-relevant-functions` — it reads the quartodoc-generated `api/**`
  pages and rewrites both fields in place, annotation-free. The generated `href` form is
  `https://shiny.posit.co/py/api/core/<page-stem>.html#shiny.<qualified-name>`
  (`components/_relevant_functions.py:23` holds the base URL; `_href()` at `:97-98` builds
  it); for a method, `<page-stem>` is the class's page and the anchor is
  `shiny.<Class>.<method>`. The `test-docs` workflow's `relevant-functions` job regenerates these on
  every PR and **fails when the committed values differ**, so never hand-edit them.
- Third-party functions (e.g. Great Tables) have no `api/**` page, so the generator can't
  resolve them: link the package's own documentation and register the title in the matching
  skip set in `components/_relevant_functions.py` (`_EXTERNAL` for third-party,
  `_NO_API_PAGE` for a `shiny.ui` export missing from py-shiny's quartodoc config) so strict
  regeneration still passes.
- **List the component's mutator functions in `relevant-functions`, not just its
  constructor.** Every server-side `update_<name>` / `insert_<name>` / `remove_<name>`
  that pairs with the component belongs here as its own `title`/`href`/`signature`
  entry (e.g. the Text Area page lists both `ui.input_text_area` and
  `ui.update_text_area`; Accordion lists `ui.accordion` plus `update_accordion`,
  `update_accordion_panel`, `insert_accordion_panel`, `remove_accordion_panel`). This
  is enforced: `components/test_ui_api_has_page.py` checks that every public `ui`
  export is documented by some page's `relevant-functions` block, and mutators are
  expected to be found on their base component's page rather than opted out. Add the
  `title:` for each and let `make docs-update-relevant-functions` fill in the rest.
  Note that this test is satisfied by a `title:` string alone — it proves the function is
  *listed*, not that it is actually explained or demonstrated.
- Each variation's **Preview** app should be its own `app-variation-<slug>-preview.py`
  (same rule as the top-level Preview — no `shinylive:` key), not a reuse of its `-core.py`.

## Shinylive links — never hand-write them

Run `make docs-update-shinylive-links` (script: `components/update-shinylive-links.py`).
It encodes each app's source into the `shinylive:` value in place. You only need a
`shinylive:` **key** present (a placeholder value is fine); the script overwrites it.

**Always re-run `make docs-update-shinylive-links` after editing, adding, or removing
any `app-*.py` file (or its `resources:`).** The `shinylive:` values are an encoding of
the app source, so any change to the source makes the committed link stale. This is not
optional: the `test-docs` workflow's `shinylive-links` job regenerates the links on
every PR and **fails the build if the committed links differ**. Regenerate and commit the
updated `index.qmd` files as part of the same change — do not leave it for later.

To rebuild just the page(s) you touched (faster than rewriting all of them), pass
`FILES=` — it accepts component dirs, `index.qmd` paths, or any file inside a component
dir (e.g. the `app-*.py` you just edited), which it resolves to the owning `index.qmd`:

```bash
make docs-update-shinylive-links FILES="components/inputs/<name>/"
make docs-update-shinylive-links FILES="components/inputs/<name>/app-core.py components/inputs/<name>/app-express.py"
```

With no `FILES`, it rewrites every component page (what CI does).

- **Multi-file apps:** if an app needs extra files, split with `## file: app.py`
  markers inside the `.py`, and list companion assets under a `resources:` key in the
  front-matter entry. The link checker warns `Multiple files in app` when a bundle has
  >1 file but no `resources:`.
- Kitchen-sink `core`/`express` URLs are full `.../editor/#h=0&code=...` links added by
  hand (they open the full editor), not generated by the script.

## Static preview cards

`make docs-static-previews` (script: `components/make-static-previews.py`) walks every
`index.qmd`, and for each with `appPreview.static: true` renders `appPreview.file` to
`components/static/.../*.html`. This is why `app-preview.py` must expose a module-level
`app_ui` and should be laid out to look good centered in a small card.

## Register in the sidebar

Add the page to `_quarto.yml` under the matching `contents:` list, kept alphabetical:

```yaml
- components/inputs/<name>/index.qmd
```

The four sections are `inputs`, `outputs`, `display-messages`, `layout`.

## Build & verify

```bash
make docs        # = components-shinylive-links + components-relevant-functions + components-static-previews
make serve             # live preview; open the new page
```

Confirm: gallery card renders, Preview tab runs live, Core/Express tabs show code with
working "Open in editor" links, variations/kitchen sink render, and the sidebar links.

### Exercise every interactive control in every demo app

Compiling / importing an app only proves it starts — it does NOT prove the buttons
work. Reactive handlers (`@reactive.event`) only run **on interaction**, so a broken
`ui.insert_*`/`ui.update_*` call raises at click time and is invisible until then. You
MUST drive each interactive `app-*.py` in a real browser and confirm every control does
what its demo claims.

For each app that has a desired behavior or interactive control — a button, switch,
slider, clickable panel/header, or even the mere appearance of an icon or rendered
element (not everything worth confirming is interactive) — run it and exercise / observe
it:

```bash
# Run one app (use the repo venv's shiny)
.venv/bin/shiny run --port 8765 components/<section>/<name>/app-variation-<slug>-express.py \
  > /tmp/app.log 2>&1 &
# wait for "Uvicorn running", then drive it in a browser (Playwright MCP) and assert
# the visible result changed the way the demo promises; finally check the app log has
# no traceback.
```

In the browser, for each control assert the *observable* effect, not just the absence of
a crash — e.g. "Add panel" adds a panel to the accordion, the switch flips the panel
titles, the slider updates the output text. Then grep the server log for
`Traceback`/`Error`. Do this for **both** the Core and Express versions and every
variation's live Preview app — bugs frequently live in only one mode (see below).

**Save the confirmation as a test — don't let it evaporate.** A one-off manual browser
drive proves the control works *today*; it does nothing to stop a future edit from
re-breaking it. Once you've confirmed the observable effect by hand, capture the same
interaction as a py-shiny Playwright test so it runs in CI on every PR. See the
[`testing-example-apps`](../testing-example-apps/SKILL.md) skill for the full workflow —
in short, add `components/<section>/<name>/test_<name>.py` next to the app files and drive
the primary Core AND Express apps with `shiny.playwright.controller`, asserting the same
observable effect you just checked by hand:

```python
from playwright.sync_api import Page
from shiny.playwright import controller
from shiny.run import ShinyAppProc

def test_core_interaction(page: Page, core_app: ShinyAppProc) -> None:
    page.goto(core_app.url)
    acc = controller.Accordion(page, "acc")
    acc.expect_open(["Section A"])
    acc.set(["Section B"])          # the click you just did by hand
    acc.expect_open(["Section B"])  # the observable effect you just confirmed
```

Smoke coverage (app starts with no server/JS/output errors) is already automatic for every
discovered `app.py`/`app-*.py` via `components/test_examples_smoke.py`, so your
`test_<name>.py` only needs the interaction assertions. Run it with
`uv run pytest components/<section>/<name>/test_<name>.py` (or `make test-components-examples`).

### Core and Express APIs can differ — verify the signature you actually call

Some server-side UI functions have a **different signature in Express than in Core**.
The classic trap is `insert_accordion_panel`:

- Core: `ui.insert_accordion_panel(id, panel, ...)` — pass a pre-built `ui.accordion_panel(...)`.
- Express: `ui.insert_accordion_panel(id, panel_title, *panel_contents, panel_value=..., ...)`
  — pass the title and body directly; there is no panel object, and in Express
  `ui.accordion_panel` is a context manager (one positional arg), so the Core form
  raises `TypeError` at click time.

Don't assume a call that works in the Core demo works verbatim in the Express demo.
Check the real signature for the mode you're in (`inspect.signature(shiny.express.ui.<fn>)`
vs `shiny.ui.<fn>`) and copy py-shiny's own Express example from
`py-shiny/shiny/api-examples/<fn>/app-express.py` when one exists.

## After the PR deploys: verify, then hand the author review links

Every PR gets a Netlify preview at `https://pr-<N>--pyshiny.netlify.app` (deployed from
`_build/`; find the exact URL via the "View deployment" button on the PR or
`gh pr checks <N>` / `gh pr view <N>`). Rendered visuals — screenshot cards, live WASM
apps, sidebar placement — are things you (Claude) can only partially judge, so the
**author must do the final visual review**. Do NOT close this out yourself.

1. **Verify first.** Wait for the deploy to finish, then load each new/changed component
   page in the preview and confirm the gallery card image rendered, the Preview app runs,
   the Core/Express tabs and their "Open in editor" links work, variations/kitchen sink
   render, and the new sidebar entry appears in the right section in the right order.
   Actually **click every button / toggle every control** in each live Preview app and
   confirm the promised effect happens with no error (see "Exercise every interactive
   control" above). Report anything broken instead of handing over links to a broken page.

2. **Then give the author direct links** — one per new/changed component, plus the
   gallery and the affected sidebar section, so they can eyeball the real output:

   ```
   Preview: https://pr-<N>--pyshiny.netlify.app

   New/changed component pages:
   - Accordion: https://pr-<N>--pyshiny.netlify.app/components/layout/accordion/

   Sidebar & gallery to check:
   - Components gallery (new card): https://pr-<N>--pyshiny.netlify.app/components/
   - Layout section sidebar (order/placement): open any page above and check the
     left sidebar shows the new entry in the right section, alphabetically placed
   ```

   Page URL = the directory path under `components/` with a trailing slash
   (`components/<section>/<name>/`). Ask the author to confirm each page and the sidebar
   before merging.

## Common mistakes

| Mistake | Fix |
|---------|-----|
| Preview tab points at `app-core.py` or `app-preview.py` | Add a dedicated `app-detail-preview.py` |
| `app-preview.py` written in Express style | Rewrite as Core with module-level `app_ui` (static renderer imports `app_ui`) |
| Hand-writing / pasting `shinylive:` values | Add the key with a placeholder, run `make docs-update-shinylive-links` |
| Non-Preview example app missing its `shinylive:` key | Add the key (script skips apps without it and only warns) |
| Inline `preview:` HTML when the app is screenshot-able | Use `appPreview: { file, static: true }` |
| Multi-file app without `resources:` | List companion files under `resources:` and split with `## file:` markers |
| Forgot `_quarto.yml` sidebar entry | Add the `index.qmd` path alphabetically in the right section |
| Empty/half-made component dir left behind | Remove it; a dir under `components/*/` without `index.qmd` used to crash the link script |
| Missing `sidebar: components` in front matter | Add it — required on every component page |
| `relevant-functions` lists only the constructor, not the `update_*`/`insert_*`/`remove_*` mutators | Add an entry per mutator (title/href/signature); `test_ui_api_has_page.py` fails when a `ui` export is documented nowhere |
| Shipping a demo whose buttons were never clicked (compiles ≠ works) | Run each interactive app and exercise every control in a browser; assert the visible effect, then save it as a `test_<name>.py` interaction test (see `testing-example-apps`) so CI keeps checking |
| Copying a Core `ui.insert_*`/`ui.update_*` call verbatim into the Express demo | Check the Express signature — it can differ (e.g. `insert_accordion_panel`); copy py-shiny's `api-examples/<fn>/app-express.py` |

