chirp-ui

Component library — layout, cards, forms, badges. Kida macros with CSS and themes

6 min read 1190 words

Overview

chirp-ui is a component library for Chirp. It provides Kida template macros — cards, modals, forms, layouts — that render as HTML. Use them with htmx for swaps, SSE for streaming, and View Transitions for polish. Zero JavaScript for layout.

What's good about it:

  • Gorgeous by default — Full visual design out of the box. Override--chirpui-*CSS variables to customize.
  • htmx-native — Interactive components use htmx or native HTML (<dialog>, <details>). No client-side framework.
  • Composable{% slot %}for content injection. Components nest freely.
  • Modern CSS:has(), container queries, fluid typography, prefers-color-schemedark mode.

Installation

Requires Python 3.14+.

pip install bengal-chirp[ui]
# or
uv add "bengal-chirp[ui]"
pip install chirp-ui
# or
uv add chirp-ui

Setup

Two steps to wire chirp-ui into your app:

1. Wire chirp-ui into your app

Use Chirp's integration helper to serve chirpui.css, themes, transitions, and register required filters:

from chirp import App, AppConfig, use_chirp_ui

app = App(AppConfig(template_dir="templates"))
use_chirp_ui(app)

Import: use_chirp_ui is provided by Chirp. Use from chirp import use_chirp_ui when the chirp[ui] extra is installed. If that fails (e.g. older Chirp), use from chirp.ext.chirp_ui import use_chirp_ui.

use_chirp_ui(app) adds StaticFiles middleware for the chirp-ui package directory (default /static) and registers filters (bem, field_errors, html_attrs, validate_variant) so chirp-ui components render correctly.

2. Include CSS in your base template

<link rel="stylesheet" href="/static/chirpui.css">

For View Transitions support, add:

<link rel="stylesheet" href="/static/chirpui-transitions.css">

Auto-detection

When chirp-ui is installed, Chirp's template loader adds the chirp-ui package automatically. No configuration needed for{% from "chirpui/..." %} imports. Templates resolve chirpui/layout.html, chirpui/card.html, etc. from the package.

Quick example

{% from "chirpui/layout.html" import container, grid, block %}
{% from "chirpui/card.html" import card %}

{% call container() %}
    {% call grid(cols=2) %}
        {% call block() %}{% call card(title="Hello") %}<p>Card one.</p>{% end %}{% end %}
        {% call block() %}{% call card(title="World") %}<p>Card two.</p>{% end %}{% end %}
    {% end %}
{% end %}

App Shell

Quick start: Extendchirpui/app_shell_layout.htmland fill the blocks. No manual HTML boilerplate:

{# target: body #}
{% extends "chirpui/app_shell_layout.html" %}
{% block brand %}My App{% end %}
{% block sidebar %}
  {% from "chirpui/sidebar.html" import sidebar, sidebar_link, sidebar_section %}
  {% call sidebar() %}
    {% call sidebar_section("Main") %}
      {{ sidebar_link("/", "Home") }}
      {{ sidebar_link("/items", "Items") }}
    {% end %}
  {% end %}
{% end %}

Adding an inner shell: For nested layouts (e.g. forum > subforum), use the shell_sectionmacro from Chirp:

{% from "chirp/macros/shell.html" import shell_section %}
{% call shell_section("forum-content") %}
  {% block content %}{% end %}
{% end %}

Migrating from boost.html: Replace {% extends "chirp/layouts/boost.html" %} with {% extends "chirpui/app_shell_layout.html" %}. Add {% block brand %}, {% block sidebar %}, etc. The hx-select="#page-content" and id="page-content"are already in place.

Page spacing contract: Let the page-level wrapper own vertical rhythm. A good pattern is a parent layout with apage_root block that contains container() + stack(gap="lg"), while inner blocks such as page_content hold the page-specific sections. Pair that with Page(..., "page_content", page_block_name="page_root", ...) or PageComposition(..., fragment_block="page_content", page_block="page_root", ...)so boosted navigation swaps the full page shell instead of a too-narrow inner fragment.

Manual shell: For full control, chirp-ui provides components for building persistent dashboard shells:sidebar, breadcrumbs, and command_palette. Combine them in a standalone _layout.html:

{# target: main #}
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>My Dashboard</title>
  <script src="https://unpkg.com/htmx.org@2.0.4"></script>
  <link rel="stylesheet" href="/static/chirpui.css">
</head>
<body>
{% from "chirpui/sidebar.html" import sidebar, sidebar_section, sidebar_link %}
{% from "chirpui/breadcrumbs.html" import breadcrumbs %}
{% from "chirpui/command_palette.html" import command_palette, command_palette_trigger %}
{% from "chirpui/toast.html" import toast_container %}

{% set cp = current_path | default("/") %}

<div class="chirpui-app-shell">
  <header class="chirpui-app-shell__topbar">
    <a href="/" class="chirpui-app-shell__brand">My App</a>
    <div class="chirpui-app-shell__topbar-center">
      {{ breadcrumbs(breadcrumb_items | default([{"label": "Home", "href": "/"}])) }}
    </div>
    <div class="chirpui-app-shell__topbar-end">
      {{ command_palette_trigger() }}
    </div>
  </header>
  <aside class="chirpui-app-shell__sidebar">
    {% call sidebar() %}
      {% call sidebar_section("Navigate") %}
        {{ sidebar_link("/", "Home", active=cp == "/") }}
        {{ sidebar_link("/items", "Items", active=cp.startswith("/items")) }}
      {% end %}
    {% end %}
  </aside>
  <main id="main" class="chirpui-app-shell__main"
        hx-boost="true" hx-target="#main"
        hx-swap="innerHTML transition:true"
        hx-select="#page-content">
    <div id="page-content">
      {% block content %}{% end %}
    </div>
  </main>
</div>

{{ command_palette(search_url="/search") }}
{{ toast_container() }}
</body>
</html>

Why standalone? Chirp's render_with_blocks({"content": ...}) replaces {% block content %} entirely. If you extend boost.html and put the shell inside {% block content %}, it gets overwritten. A standalone layout puts the shell outside the content block so it always renders. See Filesystem Routing for the full explanation.

Whyhx-select? On htmx-boosted navigation, Chirp returns a full HTML page (it renders the matched layout). Without hx-select, htmx would swap the entire response into #main, replacing the shell. hx-select="#page-content" tells htmx to parse the response and extract only #page-content— the shell stays untouched.

Why nohx-disinherit? Boosted links inside the content area need to inherit hx-target="#main", hx-swap, and hx-select from the <main> element. If you add hx-disinherit, boosted links fall back to targeting body, which replaces everything. Fragment requests with explicit hx-target (e.g. hx-target="#compare-result") override the inherited value naturally.

Component categories

Category Examples
Layout container, grid, stack, block, page_header, section_header, divider, breadcrumbs, navbar, sidebar, hero, surface, callout
UI card, card_header, modal, drawer, tabs, accordion, dropdown, popover, toast, table, pagination, alert, button_group
Forms text_field, password_field, textarea_field, select_field, checkbox_field, toggle_field, radio_field, file_field, date_field, csrf_hidden, form_actions, login_form, signup_form
Data display badge, spinner, skeleton, progress, description_list, timeline, tree_view, calendar
Streaming streaming_block, copy_btn, model_card — for htmx SSE and LLM UIs

See the chirp-ui repository for the full component reference and API.

Data layout patterns

For dashboard and settings pages, use these patterns for consistent structure:

Section with header actions — Put section-level buttons (Refresh, Auto-detect, Run validation) in thesectionactions slot, not beneath the content:

{% from "chirpui/layout.html" import section %}
{% from "chirpui/button.html" import btn %}
{% call section("Setup targets", subtitle="Configure your IDE") %}
{% slot actions %}{{ btn("Refresh", attrs_map={"hx-get": "/status", "hx-target": "#targets"}) }}{% end %}
<div id="targets">...</div>
{% end %}

Settings rows — For label + status + value (e.g. setup targets, health checks), use settings_row_list and settings_row:

{% from "chirpui/settings_row.html" import settings_row_list, settings_row %}
{% call settings_row_list() %}
{{ settings_row("Cursor IDE", status="Configured", detail="dori setup cursor") }}
{{ settings_row("Skills directory", status="ok", detail="/path/to/skills") }}
{% end %}

When to use what — Use description_list for term + detail only (no status badge). Use settings_row_listwhen you have label + status + detail.

Theming

chirp-ui usesprefers-color-scheme for dark mode. Override any --chirpui-*variable:

:root {
    --chirpui-accent: #7c3aed;
    --chirpui-container-max: 80rem;
}

For manual light/dark toggle, set data-theme="light" or data-theme="dark" on <html>.

Optional theme:<link rel="stylesheet" href="/static/themes/holy-light.css">

chirp new

When chirp-ui is installed,chirp new <name> scaffolds a project with use_chirp_ui(app)wired in the app module. The base template includes chirpui.css.

Next steps

  • chirp-ui on GitHub — Full component reference, showcase app, and development docs
  • RAG Demo — Uses chirp-ui for layout, cards, badges, and alert
  • Islands Contract — chirp-ui providesisland_rootand state primitives for high-state widgets