| name | writing-component-pages |
| description | 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. |
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):
---
title: Task Button
sidebar: components
appPreview:
file: components/inputs/task-button/app-preview.py
static: true
listing:
- id: example
template: ../../_partials/components-detail-example.ejs
template-params:
dir: components/inputs/task-button/
contents:
- title: Preview
file: app-detail-preview.py
height: 200
- title: Express
file: app-express.py
shinylive: PLACEHOLDER
- title: Core
file: app-core.py
shinylive: PLACEHOLDER
- id: relevant-functions
template: ../../_partials/components-detail-relevant-functions.ejs
[ ]
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 page, so the generator can't
resolve them: link the package's own documentation and register the title in the matching
skip set in ( for third-party,
for a export missing from py-shiny's quartodoc config) so strict
regeneration still passes.
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:
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:
- components/inputs/<name>/index.qmd
The four sections are inputs, outputs, display-messages, layout.
Build & verify
make docs
make serve
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:
.venv/bin/shiny run --port 8765 components/<section>/<name>/app-variation-<slug>-express.py \
> /tmp/app.log 2>&1 &
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 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:
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"])
acc.expect_open(["Section B"])
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.
-
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.
-
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 |