`:
+
+```html
+
+ $$Pagination$$
+
+```
+
+### Single page (hides naturally)
+
+```csharp
+// When total == 1, Prev and Next are both disabled and only "1" is rendered.
+new Pagination(current: 1, total: 1, urlPattern: "/items?page={0}")
+```
+
+---
+
+## Tips and tricks
+
+- The `urlPattern` uses `string.Format`-style `{0}` — do not use `{page}` or other named placeholders.
+- Page numbers are 1-based throughout — the first page is page `1`.
+- When `total` is 0 or negative the component renders nothing — guard `total > 1` in the page if you want to hide it entirely when there is only one page.
+- To preserve sort order or filters across pages, include those values in the `urlPattern` query string.
+- For very small page counts (2–3 pages), all page buttons are shown with no ellipsis.
+- For very small page counts (2–3 pages), all page buttons are shown with no ellipsis.
+
+---
+
+## Complete page example
+
+**`Templates/BlogPage.htmx`**
+```html
+
+
Blog
+
+ $$PostList$$
+
+ $$Pagination$$
+
+```
+
+**`Templates/BlogPage.htmx.cs`**
+```csharp
+namespace Htmx.ApiDemo.Templates;
+
+public sealed class BlogPage : BlogPageBase
+{
+ private readonly byte[] _postList;
+ private readonly IHtmxComponent _pagination;
+
+ public BlogPage(IEnumerable
posts, int currentPage, int totalPages)
+ {
+ var sb = new System.Text.StringBuilder();
+ foreach (var post in posts)
+ {
+ sb.Append($"""
+
+
+ {post.PublishedAt:MMMM d, yyyy}
+ {System.Net.WebUtility.HtmlEncode(post.Summary)}
+
+ """);
+ }
+ _postList = sb.ToString().ToUtf8Bytes();
+ _pagination = new Components.Pagination(
+ current: currentPage,
+ total: totalPages,
+ urlPattern: "/blog?page={0}");
+ }
+
+ protected override void RenderPostList(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_postList);
+ protected override void RenderPagination(HtmxRenderContext ctx) => _pagination.Render(ctx.Next());
+}
+```
+
+**GET handler**
+```csharp
+[Handler]
+[MapGet("/blog")]
+public static partial class GetBlogHandler
+{
+ public record Query([property: FromQuery] int Page = 1);
+
+ private static async Task HandleAsync(
+ Query q, HttpContext ctx, BlogService blog, CancellationToken ct)
+ {
+ const int pageSize = 10;
+ var (posts, total) = await blog.GetPageAsync(q.Page, pageSize, ct);
+ int totalPages = (int)Math.Ceiling(total / (double)pageSize);
+
+ return await ctx.WriteHtmxPage(
+ new BlogPage(posts, q.Page, totalPages), title: "Blog");
+ }
+}
+```
diff --git a/docs/Components/Progress.md b/docs/Components/Progress.md
new file mode 100644
index 0000000..e8fa321
--- /dev/null
+++ b/docs/Components/Progress.md
@@ -0,0 +1,176 @@
+# Progress
+
+A horizontal progress bar. Value is clamped to 0–100. Three sizes control the bar height.
+
+---
+
+## HTML structure
+
+```
+div.w-full.bg-secondary.rounded-full.overflow-hidden.{size class}
+ div.bg-primary.rounded-full.h-full.transition-all[style="width: {value}%"]
+```
+
+---
+
+## CSS mechanics
+
+| Class | Effect |
+|---|---|
+| `bg-secondary` | Neutral track color |
+| `bg-primary` | Filled indicator color |
+| `rounded-full overflow-hidden` | Pill-shaped track; fills also become pill-shaped |
+| `transition-all` | Smooth animation when `width` changes |
+
+**Size classes applied to the outer track:**
+
+| Size | Class | Height |
+|---|---|---|
+| `sm` | `h-1.5` | 6 px |
+| `default` | `h-2.5` | 10 px |
+| `lg` | `h-4` | 16 px |
+
+---
+
+## Constructor signature
+
+```csharp
+public Progress(int value, string size = "default")
+```
+
+| Parameter | Description |
+|---|---|
+| `value` | Fill percentage; clamped to 0–100 |
+| `size` | `"sm"` / `"default"` / `"lg"` |
+
+---
+
+## Usage examples
+
+### Inline usage
+
+```csharp
+new Progress(value: 72)
+new Progress(value: 40, size: "sm")
+new Progress(value: 100, size: "lg")
+```
+
+### Inside a Card
+
+```csharp
+new Card(
+ title: "Disk usage",
+ content: $"""
+
+ Used
+ {used} GB / {total} GB
+
+ {progressHtml}
+ """)
+```
+
+(Pre-render the `Progress` to a string using `HtmxRenderContext` and `ArrayBufferWriter`.)
+
+### HTMX live update
+
+```html
+
+ $$ProgressBar$$
+
+```
+
+The endpoint returns a partial re-render of this fragment with the updated `value`.
+
+---
+
+## Tips and tricks
+
+- Values below 0 are treated as 0; values above 100 are treated as 100 — no manual clamping needed.
+- Use `size: "sm"` for compact UI areas such as table rows.
+- To animate progress smoothly, let `transition-all` do the work: re-render the component via HTMX on a polling interval or push updates via SSE.
+- For an indeterminate spinner, use `Skeleton` instead (it has `animate-pulse` built in).
+- For an indeterminate spinner, use `Skeleton` instead (it has `animate-pulse` built in).
+
+---
+
+## Complete page example
+
+**`Templates/JobStatusPage.htmx`**
+```html
+
+
Processing
+
$$StatusText$$
+
+ Progress
+ $$ProgressLabel$$
+
+ $$ProgressBar$$
+ $$DoneAlert$$
+
+```
+
+**`Templates/JobStatusPage.htmx.cs`**
+```csharp
+namespace Htmx.ApiDemo.Templates;
+
+public sealed class JobStatusPage : JobStatusPageBase
+{
+ private readonly byte[] _statusText;
+ private readonly byte[] _progressLabel;
+ private readonly IHtmxComponent _progressBar;
+ private readonly IHtmxComponent _doneAlert;
+
+ public JobStatusPage(int percent, string statusText)
+ {
+ _statusText = System.Net.WebUtility.HtmlEncode(statusText).ToUtf8Bytes();
+ _progressLabel = $"{percent}%".ToUtf8Bytes();
+ _progressBar = new Components.Progress(value: percent);
+ _doneAlert = percent >= 100
+ ? new Components.Alert(title: "Complete!", description: "Your export is ready.")
+ : HtmxEmpty.Instance;
+ }
+
+ protected override void RenderStatusText(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_statusText);
+ protected override void RenderProgressLabel(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_progressLabel);
+ protected override void RenderProgressBar(HtmxRenderContext ctx) => _progressBar.Render(ctx.Next());
+ protected override void RenderDoneAlert(HtmxRenderContext ctx) => _doneAlert.Render(ctx.Next());
+}
+```
+
+**GET handler with HTMX polling**
+```csharp
+[Handler]
+[MapGet("/jobs/{jobId}/status")]
+public static partial class GetJobStatusHandler
+{
+ public record Query([property: FromRoute] string JobId);
+
+ private static async Task HandleAsync(
+ Query q, HttpContext ctx, JobQueue jobs, CancellationToken ct)
+ {
+ var job = await jobs.GetAsync(q.JobId, ct);
+ if (job is null) return Results.NotFound();
+
+ var page = new JobStatusPage(job.PercentComplete, job.StatusText);
+
+ // If polling (HTMX partial), only return the progress fragment
+ if (ctx.Request.Headers.ContainsKey("HX-Request"))
+ {
+ // Stop polling when done
+ if (job.PercentComplete >= 100)
+ ctx.Response.Headers.Append("HX-Trigger", "jobComplete");
+
+ return await ctx.WriteHtmxPage(page, title: "Processing");
+ }
+
+ // Full page load — include polling trigger
+ ctx.Response.Headers.Append("HX-Trigger-After-Settle",
+ """{"startPolling": {"interval": 1000, "target": "#progress-region"}}""");
+
+ return await ctx.WriteHtmxPage(page, title: "Processing");
+ }
+}
+```
diff --git a/docs/Components/RadioGroup.md b/docs/Components/RadioGroup.md
new file mode 100644
index 0000000..80aaa4b
--- /dev/null
+++ b/docs/Components/RadioGroup.md
@@ -0,0 +1,211 @@
+# RadioGroup
+
+A group of radio buttons sharing the same `name` attribute. Supports horizontal or vertical layout. One option can be pre-selected.
+
+---
+
+## HTML structure
+
+```
+div.flex.flex-col.gap-1.5
+ label.text-sm.font-medium ← group label (omitted when empty)
+ {label}
+ div.flex.{direction}.gap-3 ← flex-col or flex-row
+ label.flex.items-center.gap-2.cursor-pointer ← one per option
+ input[type=radio, name, value, class, $$Checked$$]
+ span.text-sm
+ {option label}
+```
+
+---
+
+## CSS mechanics
+
+| Class | Effect |
+|---|---|
+| `accent-primary` | Radio circle color follows `--color-primary` CSS variable |
+| `h-4 w-4` | 16×16 radio circle |
+| `cursor-pointer` | Pointer cursor on the label |
+| `flex-col` (default) | Stacks options vertically |
+| `flex-row` | Places options side by side |
+
+---
+
+## Constructor signature
+
+```csharp
+public RadioGroup(
+ string name,
+ IEnumerable<(string Value, string Label, bool Selected)> options,
+ string label = "",
+ string direction = "flex-col")
+```
+
+| Parameter | Description |
+|---|---|
+| `name` | Shared `name` attribute for all radio inputs in the group |
+| `options` | List of `(Value, Label, Selected)` tuples |
+| `label` | Optional visible group heading above the options |
+| `direction` | `"flex-col"` (vertical, default) or `"flex-row"` (horizontal) |
+
+---
+
+## Usage examples
+
+### Vertical list
+
+```csharp
+new RadioGroup(
+ name: "plan",
+ label: "Select a plan",
+ options: new[]
+ {
+ ("free", "Free", true),
+ ("pro", "Pro", false),
+ ("teams", "Teams", false),
+ })
+```
+
+### Horizontal inline options
+
+```csharp
+new RadioGroup(
+ name: "size",
+ label: "Size",
+ direction: "flex-row",
+ options: new[]
+ {
+ ("sm", "S", false),
+ ("md", "M", true),
+ ("lg", "L", false),
+ ("xl", "XL", false),
+ })
+```
+
+### Reading in a form handler
+
+```csharp
+public record Command([property: FromForm] string Plan);
+
+// command.Plan == "free" | "pro" | "teams"
+```
+
+### Dynamic options from database
+
+```csharp
+var options = categories
+ .Select((cat, i) => (cat.Slug, cat.Name, i == 0))
+ .ToArray();
+
+new RadioGroup(name: "category", label: "Category", options: options)
+```
+
+---
+
+## Tips and tricks
+
+- Only one option in the group can have `Selected = true`; if multiple are marked selected the last one wins (standard HTML behavior).
+- An unselected `RadioGroup` submits nothing — validate server-side that the field is present.
+- For a "none of the above" option, add a tuple with the intended empty value: `("", "None", false)`.
+- To conditionally show additional fields when a radio is selected, add an `htmx` attribute via inline HTML after the component — or use a custom slot that includes both the radio and a reveal div.
+- To conditionally show additional fields when a radio is selected, add an `htmx` attribute via inline HTML after the component — or use a custom slot that includes both the radio and a reveal div.
+
+---
+
+## Complete page example
+
+**`Templates/SurveyPage.htmx`**
+```html
+
+
Quick survey
+
Help us improve BeepBoop.
+
+ $$SuccessAlert$$
+
+```
+
+**`Templates/SurveyPage.htmx.cs`**
+```csharp
+namespace Htmx.ApiDemo.Templates;
+
+public sealed class SurveyPage : SurveyPageBase
+{
+ private readonly IHtmxComponent _experience;
+ private readonly IHtmxComponent _feature;
+ private readonly IHtmxComponent _submit;
+ private readonly IHtmxComponent _success;
+ private readonly byte[] _afToken;
+
+ public SurveyPage(IAntiforgery af, HttpContext ctx, bool submitted = false)
+ {
+ var tokens = af.GetAndStoreTokens(ctx);
+ _afToken = $""" """.ToUtf8Bytes();
+
+ _experience = new Components.RadioGroup(
+ name: "experience",
+ label: "How would you rate your experience?",
+ options: new[]
+ {
+ ("1", "Poor", false),
+ ("2", "Fair", false),
+ ("3", "Good", true),
+ ("4", "Very good", false),
+ ("5", "Excellent", false),
+ },
+ direction: "flex-row");
+
+ _feature = new Components.RadioGroup(
+ name: "favourite",
+ label: "Which feature do you use most?",
+ options: new[]
+ {
+ ("htmx", "HTMX integration", false),
+ ("aot", "AOT publishing", false),
+ ("generator", "Source generator", false),
+ ("tailwind", "Tailwind CSS", false),
+ });
+
+ _submit = new Components.Button("Submit", type: "submit");
+ _success = submitted
+ ? new Components.Alert(title: "Thank you!", description: "Your responses have been recorded.")
+ : HtmxEmpty.Instance;
+ }
+
+ protected override void RenderAntiforgeryToken(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_afToken);
+ protected override void RenderExperienceGroup(HtmxRenderContext ctx) => _experience.Render(ctx.Next());
+ protected override void RenderFeatureGroup(HtmxRenderContext ctx) => _feature.Render(ctx.Next());
+ protected override void RenderSubmitBtn(HtmxRenderContext ctx) => _submit.Render(ctx.Next());
+ protected override void RenderSuccessAlert(HtmxRenderContext ctx) => _success.Render(ctx.Next());
+}
+```
+
+**POST handler**
+```csharp
+[Handler]
+[MapPost("/survey")]
+public static partial class PostSurveyHandler
+{
+ public record Command(
+ [property: FromForm] string Experience,
+ [property: FromForm] string Favourite);
+
+ private static Task HandleAsync(
+ [AsParameters] Command cmd, HttpContext ctx, IAntiforgery af, CancellationToken ct)
+ {
+ // Persist responses…
+ return ctx.WriteHtmxPage(new SurveyPage(af, ctx, submitted: true), title: "Survey");
+ }
+}
+```
+
+**`AppJsonSerializerContext.cs`**
+```csharp
+[JsonSerializable(typeof(PostSurveyHandler.Command), TypeInfoPropertyName = "SurveyCommand")]
+```
diff --git a/docs/Components/Select.md b/docs/Components/Select.md
new file mode 100644
index 0000000..61afe65
--- /dev/null
+++ b/docs/Components/Select.md
@@ -0,0 +1,234 @@
+# Select
+
+A styled `` dropdown. Supports a pre-selected value, optional label, and optional description text. HTMX attributes can be added.
+
+---
+
+## HTML structure
+
+```
+div.flex.flex-col.gap-1.5
+ label[for={id}].text-sm.font-medium ← omitted when label is empty
+ {label}
+ select[id, name, class, $$HxAttrs$$]
+ option[value, $$Selected$$] ← one per option; selected="selected" when matched
+ {display}
+ p.text-sm.text-muted-foreground ← omitted when description is empty
+ {description}
+```
+
+---
+
+## CSS mechanics
+
+| Class | Effect |
+|---|---|
+| `flex h-10 w-full rounded-md border border-input bg-background` | Full-width 40px select field |
+| `px-3 py-2 text-sm` | Inner padding and text size |
+| `focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring` | Keyboard focus ring |
+| `disabled:cursor-not-allowed disabled:opacity-50` | Disabled state |
+| `bg-background` | Ensures the dropdown matches the page background in dark mode |
+
+---
+
+## Constructor signature
+
+```csharp
+public Select(
+ string id,
+ IEnumerable<(string Value, string Display)> options,
+ string? selectedValue = null,
+ string name = "",
+ string label = "",
+ string description = "",
+ string extraClasses = "",
+ string hxAttrs = "")
+```
+
+| Parameter | Description |
+|---|---|
+| `id` | Element id and label `for` target |
+| `options` | List of `(Value, Display)` tuples |
+| `selectedValue` | Pre-selected option value; `null` = no pre-selection (first option shown) |
+| `name` | Form field name |
+| `label` | Optional visible label |
+| `description` | Optional helper text below the field |
+| `extraClasses` | Additional Tailwind classes on the `` element |
+| `hxAttrs` | Verbatim HTMX / data attributes |
+
+---
+
+## Usage examples
+
+### Country selector
+
+```csharp
+new Select(
+ id: "country",
+ name: "country",
+ label: "Country",
+ options: new[]
+ {
+ ("us", "United States"),
+ ("gb", "United Kingdom"),
+ ("ca", "Canada"),
+ ("au", "Australia"),
+ },
+ selectedValue: "us")
+```
+
+### Dynamic options from data
+
+```csharp
+var options = categories.Select(c => (c.Slug, c.Name));
+
+new Select(
+ id: "category",
+ name: "category",
+ label: "Category",
+ options: options,
+ selectedValue: existingCategory)
+```
+
+### HTMX on-change reload
+
+```csharp
+new Select(
+ id: "region",
+ name: "region",
+ label: "Region",
+ options: regions,
+ hxAttrs: """hx-get="/cities" hx-target="#city-select" hx-trigger="change" hx-include="[name='region']"""")
+```
+
+### Reading in a form handler
+
+```csharp
+public record Command([property: FromForm] string Country);
+
+// command.Country == "us" | "gb" | "ca" | "au"
+```
+
+### Placeholder option (no pre-selection)
+
+```csharp
+new Select(
+ id: "role",
+ name: "role",
+ label: "Role",
+ options: new[]
+ {
+ ("", "— Select a role —"),
+ ("admin", "Administrator"),
+ ("user", "Regular user"),
+ },
+ selectedValue: "")
+```
+
+---
+
+## Tips and tricks
+
+- Pass an empty-value placeholder as the first option (`("", "Select…")`) to force the user to make an explicit selection.
+- `selectedValue` comparison is exact — make sure the value you pass matches one of the `Value` strings in `options`.
+- `hxAttrs` is verbatim — you can add `multiple`, `size`, `disabled`, `autocomplete`, or any other native attribute here.
+- To conditionally disable individual options, build the raw `` HTML manually or subclass the component.
+- To conditionally disable individual options, build the raw `` HTML manually or subclass the component.
+
+---
+
+## Complete page example
+
+**`Templates/FilterProductsPage.htmx`**
+```html
+
+
Products
+
+
+ $$ProductTable$$
+
+
+```
+
+**`Templates/FilterProductsPage.htmx.cs`**
+```csharp
+namespace Htmx.ApiDemo.Templates;
+
+public sealed class FilterProductsPage : FilterProductsPageBase
+{
+ private readonly IHtmxComponent _category;
+ private readonly IHtmxComponent _sort;
+ private readonly IHtmxComponent _table;
+
+ public FilterProductsPage(
+ IEnumerable products,
+ string selectedCategory = "",
+ string selectedSort = "name-asc")
+ {
+ _category = new Components.Select(
+ id: "category",
+ name: "category",
+ label: "Category",
+ options: new[]
+ {
+ ("", "All categories"),
+ ("electronics","Electronics"),
+ ("clothing", "Clothing"),
+ ("books", "Books"),
+ },
+ selectedValue: selectedCategory);
+
+ _sort = new Components.Select(
+ id: "sort",
+ name: "sort",
+ label: "Sort by",
+ options: new[]
+ {
+ ("name-asc", "Name A–Z"),
+ ("name-desc", "Name Z–A"),
+ ("price-asc", "Price: low to high"),
+ ("price-desc", "Price: high to low"),
+ },
+ selectedValue: selectedSort);
+
+ _table = new Components.Table(
+ headers: new[] { "Name", "Category", "Price" },
+ rows: products.Select(p => new[]
+ {
+ System.Net.WebUtility.HtmlEncode(p.Name),
+ System.Net.WebUtility.HtmlEncode(p.Category),
+ $"${p.Price:F2}",
+ }));
+ }
+
+ protected override void RenderCategorySelect(HtmxRenderContext ctx) => _category.Render(ctx.Next());
+ protected override void RenderSortSelect(HtmxRenderContext ctx) => _sort.Render(ctx.Next());
+ protected override void RenderProductTable(HtmxRenderContext ctx) => _table.Render(ctx.Next());
+}
+```
+
+**GET handler (full page + HTMX partial)**
+```csharp
+[Handler]
+[MapGet("/products")]
+public static partial class GetProductsHandler
+{
+ public record Query(
+ [property: FromQuery] string Category = "",
+ [property: FromQuery] string Sort = "name-asc");
+
+ private static async Task HandleAsync(
+ Query q, HttpContext ctx, ProductService products, CancellationToken ct)
+ {
+ var items = await products.FilterAsync(q.Category, q.Sort, ct);
+ return await ctx.WriteHtmxPage(
+ new FilterProductsPage(items, q.Category, q.Sort), title: "Products");
+ }
+}
+```
diff --git a/docs/Components/Separator.md b/docs/Components/Separator.md
new file mode 100644
index 0000000..8ef91dd
--- /dev/null
+++ b/docs/Components/Separator.md
@@ -0,0 +1,148 @@
+# Separator
+
+A thin divider line. Renders as a horizontal ` ` or a vertical bar depending on orientation.
+
+---
+
+## HTML structure
+
+**Horizontal:**
+```
+hr.border-t.border-border.my-4.{extraClasses}
+```
+
+**Vertical:**
+```
+span.inline-block.border-l.border-border.mx-2.h-4.{extraClasses}
+```
+
+---
+
+## CSS mechanics
+
+| Class | Effect |
+|---|---|
+| `border-t border-border` | Top border in the theme's border color (horizontal) |
+| `border-l border-border` | Left border in the theme's border color (vertical) |
+| `my-4` | Default vertical margin for horizontal separators |
+| `mx-2` | Default horizontal margin for vertical separators |
+| `h-4` | 16px height for vertical separators |
+
+---
+
+## Constructor signature
+
+```csharp
+public Separator(
+ string orientation = "horizontal",
+ string extraClasses = "")
+```
+
+| Parameter | Description |
+|---|---|
+| `orientation` | `"horizontal"` (default) or `"vertical"` |
+| `extraClasses` | Additional Tailwind classes on the element |
+
+---
+
+## Usage examples
+
+### Horizontal divider
+
+```csharp
+new Separator()
+```
+
+### Vertical divider in a flex toolbar
+
+```html
+
+ Bold
+ $$VertSep$$
+ Italic
+ $$VertSep$$
+ Underline
+
+```
+
+```csharp
+var VertSep = new Separator(orientation: "vertical");
+```
+
+### Custom margin
+
+```csharp
+new Separator(extraClasses: "my-8") // extra vertical space
+new Separator(extraClasses: "my-0 mt-2") // override default margin
+```
+
+---
+
+## Tips and tricks
+
+- The horizontal `Separator` is an ` ` element — it carries semantic meaning as a thematic break. Use it between content sections.
+- The vertical `Separator` is an inline `` — use it inside `flex` rows (toolbars, breadcrumb rows, stat rows, etc.).
+- Override margins using `extraClasses` when the default `my-4` / `mx-2` doesn't fit the surrounding layout.
+- Override margins using `extraClasses` when the default `my-4` / `mx-2` doesn't fit the surrounding layout.
+
+---
+
+## Complete page example
+
+**`Templates/AboutPage.htmx`**
+```html
+
+
About BeepBoop
+
A fast AOT-safe HTMX framework for .NET 10.
+ $$SectionSep1$$
+
Mission
+
$$MissionText$$
+ $$SectionSep2$$
+
Team
+
+ Alice
+ $$InlineSep$$
+ Bob
+ $$InlineSep$$
+ Carol
+
+
+```
+
+**`Templates/AboutPage.htmx.cs`**
+```csharp
+namespace Htmx.ApiDemo.Templates;
+
+public sealed class AboutPage : AboutPageBase
+{
+ private readonly IHtmxComponent _sep1;
+ private readonly IHtmxComponent _sep2;
+ private readonly IHtmxComponent _inline;
+ private readonly byte[] _mission;
+
+ public AboutPage()
+ {
+ _sep1 = new Components.Separator();
+ _sep2 = new Components.Separator();
+ _inline = new Components.Separator(orientation: "vertical");
+ _mission = "BeepBoop makes building server-rendered HTMX apps as fast and safe as possible.".ToUtf8Bytes();
+ }
+
+ protected override void RenderSectionSep1(HtmxRenderContext ctx) => _sep1.Render(ctx.Next());
+ protected override void RenderSectionSep2(HtmxRenderContext ctx) => _sep2.Render(ctx.Next());
+ protected override void RenderInlineSep(HtmxRenderContext ctx) => _inline.Render(ctx.Next());
+ protected override void RenderMissionText(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_mission);
+}
+```
+
+**GET handler**
+```csharp
+[Handler]
+[MapGet("/about")]
+public static partial class GetAboutHandler
+{
+ public record Query();
+ private static Task HandleAsync(Query _, HttpContext ctx, CancellationToken ct)
+ => ctx.WriteHtmxPage(new AboutPage(), title: "About");
+}
+```
diff --git a/docs/Components/Skeleton.md b/docs/Components/Skeleton.md
new file mode 100644
index 0000000..819ae4a
--- /dev/null
+++ b/docs/Components/Skeleton.md
@@ -0,0 +1,185 @@
+# Skeleton
+
+An animated loading placeholder. Use it in place of real content while data is being fetched or rendered asynchronously. The animation communicates to the user that content is loading.
+
+---
+
+## HTML structure
+
+```
+div.animate-pulse.rounded-md.bg-muted.{classes}
+```
+
+---
+
+## CSS mechanics
+
+| Class | Effect |
+|---|---|
+| `animate-pulse` | Tailwind's built-in fade-in/out animation (1.5s loop) |
+| `bg-muted` | Neutral muted background color from the theme |
+| `rounded-md` | Slightly rounded corners |
+| User-supplied `classes` | Control size and shape (e.g. `h-4 w-32`, `h-10 w-full`, `rounded-full h-12 w-12`) |
+
+---
+
+## Constructor signature
+
+```csharp
+public Skeleton(string classes = "")
+```
+
+| Parameter | Description |
+|---|---|
+| `classes` | Tailwind classes controlling size, shape, and spacing |
+
+---
+
+## Usage examples
+
+### Text line placeholders
+
+```csharp
+new Skeleton("h-4 w-3/4 mb-2")
+new Skeleton("h-4 w-1/2")
+```
+
+### Avatar placeholder
+
+```csharp
+new Skeleton("rounded-full h-12 w-12")
+```
+
+### Card skeleton loader
+
+```csharp
+new Card(
+ content: """
+
+
+
+
+
+ """)
+```
+
+### Full-width block placeholder
+
+```csharp
+new Skeleton("h-10 w-full")
+```
+
+### HTMX skeleton swap pattern
+
+```html
+
+
+ $$UserListSkeleton$$
+
+```
+
+The page renders the skeleton on initial load; the HTMX request fires immediately and replaces it once the data arrives.
+
+---
+
+## Tips and tricks
+
+- Multiple `Skeleton` elements stacked in a `div.space-y-2` create a convincing text-block placeholder.
+- `rounded-full` makes a circle — useful for avatar skeletons. Combine with equal `h-*` and `w-*` values.
+- The `classes` parameter replaces the default empty string — provide complete size + spacing classes.
+- For table skeletons, render a `Table` with each cell containing a Skeleton HTML string (pre-rendered to a string via `ArrayBufferWriter`).
+- Do not use Skeleton for truly empty states (no data to show) — use an `Alert` or empty-state illustration instead.
+- Do not use Skeleton for truly empty states (no data to show) — use an `Alert` or empty-state illustration instead.
+
+---
+
+## Complete page example
+
+**`Templates/UserListPage.htmx`**
+```html
+
+
Users
+
+ $$LoadingSkeleton$$
+
+
+```
+
+**`Templates/UserListPage.htmx.cs`**
+```csharp
+namespace Htmx.ApiDemo.Templates;
+
+public sealed class UserListPage : UserListPageBase
+{
+ private readonly byte[] _skeleton;
+
+ public UserListPage()
+ {
+ // Build a table-shaped skeleton: 5 rows × 3 columns
+ var row = new System.Text.StringBuilder();
+ for (int i = 0; i < 5; i++)
+ {
+ row.Append("""""");
+ row.Append(SkeletonHtml("h-4 w-1/3"));
+ row.Append(SkeletonHtml("h-4 w-1/4"));
+ row.Append(SkeletonHtml("h-4 w-1/5"));
+ row.Append("
");
+ }
+ _skeleton = row.ToString().ToUtf8Bytes();
+ }
+
+ private static string SkeletonHtml(string classes)
+ {
+ var buf = new System.Buffers.ArrayBufferWriter();
+ new Components.Skeleton(classes).Render(new HtmxRenderContext(buf));
+ return System.Text.Encoding.UTF8.GetString(buf.WrittenSpan);
+ }
+
+ protected override void RenderLoadingSkeleton(HtmxRenderContext ctx)
+ => ctx.Writer.WriteUtf8(_skeleton);
+}
+```
+
+**GET handlers**
+```csharp
+// Shell page — renders immediately with skeleton placeholder
+[Handler]
+[MapGet("/users")]
+public static partial class GetUsersShellHandler
+{
+ public record Query();
+ private static Task HandleAsync(Query _, HttpContext ctx, CancellationToken ct)
+ => ctx.WriteHtmxPage(new UserListPage(), title: "Users");
+}
+
+// Data endpoint — HTMX swaps this in place of the skeleton
+[Handler]
+[MapGet("/users/data")]
+public static partial class GetUsersDataHandler
+{
+ public record Query();
+ private static async Task HandleAsync(
+ Query _, HttpContext ctx, MongoDbService db, CancellationToken ct)
+ {
+ var users = await db.GetAllUsersAsync(ct);
+ var table = new Components.Table(
+ headers: new[] { "Name", "Email", "Role" },
+ rows: users.Select(u => new[]
+ {
+ System.Net.WebUtility.HtmlEncode(u.DisplayName ?? ""),
+ System.Net.WebUtility.HtmlEncode(u.Email),
+ "user",
+ }));
+ var buf = new System.Buffers.ArrayBufferWriter();
+ table.Render(new HtmxRenderContext(buf));
+ return Results.Content(
+ System.Text.Encoding.UTF8.GetString(buf.WrittenSpan), "text/html");
+ }
+}
+```
diff --git a/docs/Components/Slider.md b/docs/Components/Slider.md
new file mode 100644
index 0000000..bbbacdf
--- /dev/null
+++ b/docs/Components/Slider.md
@@ -0,0 +1,203 @@
+# Slider
+
+A styled ` ` with optional label and description. Supports min/max/step/value and HTMX attributes.
+
+---
+
+## HTML structure
+
+```
+div.flex.flex-col.gap-1.5
+ label[for={id}].text-sm.font-medium ← omitted when label is empty
+ {label}
+ input[type=range, id, name, min, max, step, value, class, $$HxAttrs$$]
+ p.text-sm.text-muted-foreground ← omitted when description is empty
+ {description}
+```
+
+---
+
+## CSS mechanics
+
+| Class | Effect |
+|---|---|
+| `w-full h-2 rounded-lg appearance-none cursor-pointer accent-primary` | Full-width, pill-shaped track; thumb follows primary color |
+| `bg-secondary` | Track fill color |
+| `accent-primary` | Thumb and active track color follows `--color-primary` |
+
+---
+
+## Constructor signature
+
+```csharp
+public Slider(
+ string id,
+ string name = "",
+ int min = 0,
+ int max = 100,
+ int step = 1,
+ int value = 50,
+ string label = "",
+ string description = "",
+ string extraClasses = "",
+ string hxAttrs = "")
+```
+
+| Parameter | Description |
+|---|---|
+| `id` | Element id and label `for` target |
+| `name` | Form field name |
+| `min` | Minimum value (default: 0) |
+| `max` | Maximum value (default: 100) |
+| `step` | Increment step (default: 1) |
+| `value` | Initial value (default: 50) |
+| `label` | Optional visible label |
+| `description` | Optional helper text |
+| `extraClasses` | Additional Tailwind classes on the input |
+| `hxAttrs` | Verbatim HTMX / data attributes |
+
+---
+
+## Usage examples
+
+### Basic 0–100 slider
+
+```csharp
+new Slider(
+ id: "volume",
+ name: "volume",
+ label: "Volume")
+```
+
+### Fixed range with step
+
+```csharp
+new Slider(
+ id: "brightness",
+ name: "brightness",
+ min: 10,
+ max: 100,
+ step: 10,
+ value: 70,
+ label: "Brightness",
+ description: "10–100")
+```
+
+### Live HTMX update
+
+```csharp
+new Slider(
+ id: "fontSize",
+ name: "fontSize",
+ min: 12,
+ max: 24,
+ value: 16,
+ label: "Font size",
+ hxAttrs: """hx-post="/settings/font-size" hx-trigger="change" hx-include="[name='fontSize']"""")
+```
+
+### Reading in a form handler
+
+```csharp
+public record Command([property: FromForm] int Volume);
+
+// command.Volume is the slider value at submit time
+```
+
+---
+
+## Tips and tricks
+
+- Display the current numeric value next to the slider by adding a small `` element and a JS `input` event listener: `slider.addEventListener('input', e => output.value = e.target.value)`. This can be added via `hxAttrs: "oninput=\"document.getElementById('vol-val').textContent=this.value\""`.
+- `value` is the **initial** server-rendered position. After the user moves the slider, only the form submission captures the new value.
+- Use `step` to snap to meaningful increments (e.g. `step: 5` for a 0–100 percentage slider).
+- `accent-primary` is supported in all modern browsers and requires no custom CSS.
+- `accent-primary` is supported in all modern browsers and requires no custom CSS.
+
+---
+
+## Complete page example
+
+**`Templates/AudioSettingsPage.htmx`**
+```html
+
+
Audio settings
+
+ $$SuccessAlert$$
+
+```
+
+**`Templates/AudioSettingsPage.htmx.cs`**
+```csharp
+namespace Htmx.ApiDemo.Templates;
+
+public sealed class AudioSettingsPage : AudioSettingsPageBase
+{
+ private readonly IHtmxComponent _volume;
+ private readonly IHtmxComponent _bass;
+ private readonly IHtmxComponent _treble;
+ private readonly IHtmxComponent _save;
+ private readonly IHtmxComponent _success;
+ private readonly byte[] _afToken;
+
+ public AudioSettingsPage(
+ IAntiforgery af,
+ HttpContext ctx,
+ AudioPrefs? prefs = null,
+ bool saved = false)
+ {
+ var tokens = af.GetAndStoreTokens(ctx);
+ _afToken = $""" """.ToUtf8Bytes();
+
+ _volume = new Components.Slider(id: "volume", name: "volume", label: "Volume", value: prefs?.Volume ?? 70, description: "0 – 100");
+ _bass = new Components.Slider(id: "bass", name: "bass", label: "Bass", value: prefs?.Bass ?? 50, min: -10, max: 10, step: 1);
+ _treble = new Components.Slider(id: "treble", name: "treble", label: "Treble", value: prefs?.Treble ?? 50, min: -10, max: 10, step: 1);
+ _save = new Components.Button("Save", type: "submit");
+ _success = saved ? new Components.Alert(title: "Audio settings saved.") : HtmxEmpty.Instance;
+ }
+
+ protected override void RenderAntiforgeryToken(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_afToken);
+ protected override void RenderVolumeSlider(HtmxRenderContext ctx) => _volume.Render(ctx.Next());
+ protected override void RenderBassSlider(HtmxRenderContext ctx) => _bass.Render(ctx.Next());
+ protected override void RenderTrebleSlider(HtmxRenderContext ctx) => _treble.Render(ctx.Next());
+ protected override void RenderSaveBtn(HtmxRenderContext ctx) => _save.Render(ctx.Next());
+ protected override void RenderSuccessAlert(HtmxRenderContext ctx) => _success.Render(ctx.Next());
+}
+```
+
+**POST handler**
+```csharp
+[Handler]
+[MapPost("/settings/audio")]
+public static partial class PostAudioSettingsHandler
+{
+ public record Command(
+ [property: FromForm] int Volume,
+ [property: FromForm] int Bass,
+ [property: FromForm] int Treble);
+
+ private static Task HandleAsync(
+ [AsParameters] Command cmd, HttpContext ctx, IAntiforgery af, CancellationToken ct)
+ {
+ var prefs = new AudioPrefs(cmd.Volume, cmd.Bass, cmd.Treble);
+ // Persist prefs…
+ return ctx.WriteHtmxPage(
+ new AudioSettingsPage(af, ctx, prefs, saved: true), title: "Audio settings");
+ }
+}
+
+public record AudioPrefs(int Volume, int Bass, int Treble);
+```
+
+**`AppJsonSerializerContext.cs`**
+```csharp
+[JsonSerializable(typeof(PostAudioSettingsHandler.Command), TypeInfoPropertyName = "AudioSettingsCommand")]
+```
diff --git a/docs/Components/Switch.md b/docs/Components/Switch.md
new file mode 100644
index 0000000..4249735
--- /dev/null
+++ b/docs/Components/Switch.md
@@ -0,0 +1,210 @@
+# Switch
+
+A toggle switch (on/off). Renders as a hidden ` ` with a styled track and thumb driven by JavaScript. Fires no custom events — read the underlying checkbox value in form submissions.
+
+---
+
+## HTML structure
+
+```
+label[for={id}].flex.items-center.gap-3.cursor-pointer
+ div.switch-root.relative.w-11.h-6.rounded-full ← outer track
+ input[type=checkbox, id, name, class="sr-only", $$Checked$$] ← hidden; holds true state
+ div.switch-thumb.absolute.top-0.5.left-0.5... ← animated thumb
+ span.text-sm.select-none ← label text (omitted when empty)
+ {label}
+```
+
+---
+
+## CSS mechanics
+
+| Class | Effect |
+|---|---|
+| `sr-only` | Hides the real checkbox visually but keeps it accessible |
+| `switch-root` | `bg-input` (off) / `bg-primary` (on) — toggled by JS adding `switch-on` class |
+| `switch-thumb` | `h-5 w-5 rounded-full bg-background shadow transition-transform` |
+| `translate-x-5` | Added to thumb by JS when switch is on (slides right) |
+
+---
+
+## JavaScript (`initSwitch` in `components.js`)
+
+Runs on `DOMContentLoaded` and `htmx:afterSwap`.
+
+**Per-switch initialization:**
+
+1. Guard `_switchInit` prevents double-binding
+2. Sync visual state from the hidden checkbox `checked` property on load
+3. On `label` click: toggle `checked`, toggle `switch-on` on the track, toggle `translate-x-5` on the thumb
+
+---
+
+## Constructor signature
+
+```csharp
+public Switch(
+ string id,
+ string label = "",
+ string name = "",
+ bool isChecked = false)
+```
+
+| Parameter | Description |
+|---|---|
+| `id` | Element id for the hidden checkbox; label's `for` attribute |
+| `label` | Optional visible text to the right of the toggle |
+| `name` | Form field name for the hidden checkbox |
+| `isChecked` | Initial on/off state |
+
+---
+
+## Usage examples
+
+### Basic on/off toggle
+
+```csharp
+new Switch(
+ id: "notifications",
+ label: "Enable notifications",
+ name: "enableNotifications",
+ isChecked: true)
+```
+
+### Toggle without label
+
+```csharp
+new Switch(id: "darkMode", name: "darkMode")
+```
+
+### Reading in a form handler
+
+```csharp
+public record Command(
+ [property: FromForm] string? EnableNotifications = null
+);
+
+bool notificationsOn = command.EnableNotifications != null;
+```
+
+> Like all checkboxes, an unchecked switch is not included in the form submission. Use `null` as the default in your command record.
+
+### HTMX auto-save on change
+
+```csharp
+// The hidden checkbox is named, so wrap in a form or use hx-include:
+new Switch(
+ id: "maintenance",
+ name: "maintenanceMode",
+ label: "Maintenance mode",
+ isChecked: currentState)
+```
+
+```html
+
+```
+
+---
+
+## Tips and tricks
+
+- The hidden checkbox carries the value `"on"` when checked (standard checkbox default). If you need `"true"`, add `value="true"` by subclassing or via a wrapper form.
+- Because the click is handled on the `` element, the switch works correctly even when the hidden input is not directly clicked.
+- For an HTMX auto-save switch, trigger on `change` from the hidden checkbox using `hx-trigger="change from:#myId"` on a parent element.
+- Pairing `isChecked` with a server-read preference ensures the switch reflects the saved state on every page load.
+- Pairing `isChecked` with a server-read preference ensures the switch reflects the saved state on every page load.
+
+---
+
+## Complete page example
+
+**`Templates/NotificationsPage.htmx`**
+```html
+
+
Notifications
+
+ $$SuccessAlert$$
+
+```
+
+**`Templates/NotificationsPage.htmx.cs`**
+```csharp
+namespace Htmx.ApiDemo.Templates;
+
+public sealed class NotificationsPage : NotificationsPageBase
+{
+ private readonly IHtmxComponent _email;
+ private readonly IHtmxComponent _push;
+ private readonly IHtmxComponent _sms;
+ private readonly IHtmxComponent _save;
+ private readonly IHtmxComponent _success;
+ private readonly byte[] _afToken;
+
+ public NotificationsPage(
+ IAntiforgery af,
+ HttpContext ctx,
+ NotificationPrefs? prefs = null,
+ bool saved = false)
+ {
+ var tokens = af.GetAndStoreTokens(ctx);
+ _afToken = $""" """.ToUtf8Bytes();
+
+ _email = new Components.Switch(id: "email-notif", label: "Email notifications", name: "emailNotif", isChecked: prefs?.Email ?? true);
+ _push = new Components.Switch(id: "push-notif", label: "Push notifications", name: "pushNotif", isChecked: prefs?.Push ?? false);
+ _sms = new Components.Switch(id: "sms-notif", label: "SMS notifications", name: "smsNotif", isChecked: prefs?.Sms ?? false);
+ _save = new Components.Button("Save", type: "submit");
+ _success = saved ? new Components.Alert(title: "Notification preferences saved.") : HtmxEmpty.Instance;
+ }
+
+ protected override void RenderAntiforgeryToken(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_afToken);
+ protected override void RenderEmailSwitch(HtmxRenderContext ctx) => _email.Render(ctx.Next());
+ protected override void RenderPushSwitch(HtmxRenderContext ctx) => _push.Render(ctx.Next());
+ protected override void RenderSmsSwitch(HtmxRenderContext ctx) => _sms.Render(ctx.Next());
+ protected override void RenderSaveBtn(HtmxRenderContext ctx) => _save.Render(ctx.Next());
+ protected override void RenderSuccessAlert(HtmxRenderContext ctx) => _success.Render(ctx.Next());
+}
+```
+
+**POST handler**
+```csharp
+[Handler]
+[MapPost("/notifications")]
+public static partial class PostNotificationsHandler
+{
+ public record Command(
+ [property: FromForm] string? EmailNotif = null,
+ [property: FromForm] string? PushNotif = null,
+ [property: FromForm] string? SmsNotif = null);
+
+ private static Task HandleAsync(
+ [AsParameters] Command cmd, HttpContext ctx, IAntiforgery af, CancellationToken ct)
+ {
+ var prefs = new NotificationPrefs(
+ Email: cmd.EmailNotif != null,
+ Push: cmd.PushNotif != null,
+ Sms: cmd.SmsNotif != null);
+ // Persist prefs…
+ return ctx.WriteHtmxPage(
+ new NotificationsPage(af, ctx, prefs, saved: true), title: "Notifications");
+ }
+}
+
+public record NotificationPrefs(bool Email, bool Push, bool Sms);
+```
+
+**`AppJsonSerializerContext.cs`**
+```csharp
+[JsonSerializable(typeof(PostNotificationsHandler.Command), TypeInfoPropertyName = "NotificationsCommand")]
+```
diff --git a/docs/Components/Table.md b/docs/Components/Table.md
new file mode 100644
index 0000000..85a7400
--- /dev/null
+++ b/docs/Components/Table.md
@@ -0,0 +1,200 @@
+# Table
+
+A styled HTML data table with a header row, optional caption, optional footer row, and one or more data rows. All data is plain strings.
+
+---
+
+## HTML structure
+
+```
+div.overflow-auto.rounded-md.border.border-border
+ table.w-full.text-sm.caption-bottom
+ caption.mt-4.text-sm.text-muted-foreground ← omitted when empty
+ {caption}
+ thead
+ tr.border-b.bg-muted/50
+ th.h-12.px-4.text-left.font-medium ← one per header
+ {header}
+ tbody
+ tr.border-b.hover:bg-muted/40 ← one per row; last row has no border
+ td.p-4 ← one per cell; raw HTML
+ {cell}
+ tfoot ← omitted when empty
+ tr
+ td[colspan=N].p-4.text-muted-foreground
+ {footer}
+```
+
+---
+
+## CSS mechanics
+
+| Class | Effect |
+|---|---|
+| `overflow-auto` on wrapper | Horizontal scroll on small screens |
+| `bg-muted/50` on header | Slightly tinted header row |
+| `hover:bg-muted/40` on data rows | Subtle hover highlight |
+| `border-b` on rows | Row separator lines |
+| `caption-bottom` | Caption appears below the table |
+
+---
+
+## Constructor signature
+
+```csharp
+public Table(
+ IEnumerable headers,
+ IEnumerable> rows,
+ string caption = "",
+ string footer = "")
+```
+
+| Parameter | Description |
+|---|---|
+| `headers` | Column heading strings |
+| `rows` | Each inner `IEnumerable` is one row; cells are raw HTML |
+| `caption` | Optional caption below the table |
+| `footer` | Optional footer cell (spans all columns) |
+
+---
+
+## Usage examples
+
+### Basic data table
+
+```csharp
+new Table(
+ headers: new[] { "Name", "Email", "Role" },
+ rows: users.Select(u => new[] { u.DisplayName ?? "", u.Email, u.Role }))
+```
+
+### With caption and footer
+
+```csharp
+new Table(
+ headers: new[] { "Product", "Price", "Stock" },
+ rows: products.Select(p => new[]
+ {
+ p.Name,
+ $"${p.Price:F2}",
+ p.Stock.ToString()
+ }),
+ caption: $"Showing {products.Count} products",
+ footer: "Prices include VAT")
+```
+
+### Cells with HTML content (e.g. badges)
+
+```csharp
+// Pre-render a Badge to HTML string
+string ActiveBadge()
+{
+ var buf = new System.Buffers.ArrayBufferWriter();
+ new Badge("Active").Render(new HtmxRenderContext(buf));
+ return System.Text.Encoding.UTF8.GetString(buf.WrittenSpan);
+}
+
+new Table(
+ headers: new[] { "Name", "Status" },
+ rows: users.Select(u => new[]
+ {
+ System.Web.HttpUtility.HtmlEncode(u.DisplayName ?? ""),
+ u.IsActive ? ActiveBadge() : ""
+ }))
+```
+
+### With action buttons per row
+
+```csharp
+string EditBtn(string id) => $"""
+ Edit
+""";
+
+new Table(
+ headers: new[] { "Name", "Actions" },
+ rows: users.Select(u => new[]
+ {
+ System.Web.HttpUtility.HtmlEncode(u.DisplayName ?? ""),
+ EditBtn(u.Id!)
+ }))
+```
+
+---
+
+## Tips and tricks
+
+- Cells are raw HTML — HTML-encode any user-supplied string values before inserting them to prevent XSS.
+- Use `System.Web.HttpUtility.HtmlEncode(value)` or `System.Net.WebUtility.HtmlEncode(value)` for any untrusted data.
+- Pair with `Pagination` below the table for large datasets.
+- For sortable columns, replace header strings with anchor tags containing HTMX sort-request attributes.
+- The `footer` string spans all columns — use it for totals, notes, or a "load more" link.
+- The `footer` string spans all columns — use it for totals, notes, or a "load more" link.
+
+---
+
+## Complete page example
+
+**`Templates/AdminUsersPage.htmx`**
+```html
+
+
+
Users
+ $$InviteBtn$$
+
+ $$UsersTable$$
+
+```
+
+**`Templates/AdminUsersPage.htmx.cs`**
+```csharp
+namespace Htmx.ApiDemo.Templates;
+
+public sealed class AdminUsersPage : AdminUsersPageBase
+{
+ private readonly IHtmxComponent _table;
+ private readonly IHtmxComponent _invite;
+
+ public AdminUsersPage(IEnumerable users, int total)
+ {
+ _invite = new Components.Button(
+ "Invite user",
+ variant: "default",
+ extraAttributes: """hx-get="/admin/users/invite" hx-target="#modal" hx-swap="innerHTML" """);
+
+ var rows = users.Select(u => new[]
+ {
+ System.Net.WebUtility.HtmlEncode(u.DisplayName ?? ""),
+ System.Net.WebUtility.HtmlEncode(u.Email),
+ u.CreatedAt.ToString("yyyy-MM-dd"),
+ $"""Delete """,
+ });
+
+ _table = new Components.Table(
+ caption: "All registered accounts",
+ headers: new[] { "Name", "Email", "Joined", "Actions" },
+ rows: rows,
+ footer: $"{total} users total");
+ }
+
+ protected override void RenderInviteBtn(HtmxRenderContext ctx) => _invite.Render(ctx.Next());
+ protected override void RenderUsersTable(HtmxRenderContext ctx) => _table.Render(ctx.Next());
+}
+```
+
+**GET handler**
+```csharp
+[Handler]
+[MapGet("/admin/users")]
+public static partial class GetAdminUsersHandler
+{
+ public record Query();
+ private static async Task HandleAsync(
+ Query _, HttpContext ctx, MongoDbService db, CancellationToken ct)
+ {
+ var users = await db.GetAllUsersAsync(ct);
+ var list = users.ToList();
+ return await ctx.WriteHtmxPage(
+ new AdminUsersPage(list, list.Count), title: "Admin – Users");
+ }
+}
+```
diff --git a/docs/Components/Tabs.md b/docs/Components/Tabs.md
new file mode 100644
index 0000000..68da18c
--- /dev/null
+++ b/docs/Components/Tabs.md
@@ -0,0 +1,200 @@
+# Tabs
+
+A tabbed interface. One tab panel is visible at a time. The active tab has a highlighted style; all others are hidden. Client-side JS switches panels without a server round-trip.
+
+---
+
+## HTML structure
+
+```
+div[id].tabs-root
+ div.tabs-list.flex.gap-1.border-b.mb-4 ← tab button strip
+ button.tabs-trigger[data-tab={tabId}] ← one per tab; ACTIVE/INACTIVE variant
+ {label}
+ div.tabs-panel[data-tab={tabId}] ← one per tab; hidden or visible
+ {content}
+```
+
+---
+
+## CSS mechanics
+
+| Class | Effect |
+|---|---|
+| `tabs-trigger` | `px-4 py-2 text-sm font-medium rounded-t-md -mb-px` |
+| Active trigger | `bg-background border border-b-0 border-border text-foreground` |
+| Inactive trigger | `text-muted-foreground hover:text-foreground hover:bg-muted/40` |
+| `tabs-panel[hidden]` | `display: none` via the HTML `hidden` attribute |
+
+---
+
+## JavaScript (`initTabs` in `components.js`)
+
+Runs on `DOMContentLoaded` and `htmx:afterSwap`.
+
+**Per-instance initialization:**
+
+1. Guard `_tabsInit` prevents double-binding
+2. Reads all `.tabs-trigger` and `.tabs-panel` elements within the root
+3. Activates the first tab on init (removes `hidden`, applies active class)
+4. On trigger click:
+ - Deactivate all panels (set `hidden`, downgrade trigger class to inactive)
+ - Activate the clicked panel by matching `data-tab` attribute
+ - Apply active class to the clicked trigger
+
+---
+
+## Constructor signature
+
+```csharp
+public Tabs(
+ string id,
+ IEnumerable<(string Id, string Label, string Content)> tabs)
+```
+
+| Parameter | Description |
+|---|---|
+| `id` | Root element id — must be unique per page if multiple Tabs are rendered |
+| `tabs` | List of `(Id, Label, Content)` tuples; `Id` must be unique within this instance |
+
+---
+
+## Usage examples
+
+### Simple tabbed content
+
+```csharp
+new Tabs(
+ id: "settings-tabs",
+ tabs: new[]
+ {
+ ("general", "General", "General settings content here.
"),
+ ("security", "Security", "Security settings content here.
"),
+ ("billing", "Billing", "Billing details here.
"),
+ })
+```
+
+### HTML-rich content in a tab
+
+```csharp
+new Tabs(
+ id: "code-tabs",
+ tabs: new[]
+ {
+ ("csharp", "C#", "var x = 42; "),
+ ("fsharp", "F#", "let x = 42 "),
+ ("vb", "VB.NET", "Dim x As Integer = 42 "),
+ })
+```
+
+### Embedding a full component in a tab
+
+```csharp
+// Pre-render the inner component to HTML string
+var buf = new System.Buffers.ArrayBufferWriter();
+new Table(headers: cols, rows: data).Render(new HtmxRenderContext(buf));
+var tableHtml = System.Text.Encoding.UTF8.GetString(buf.WrittenSpan);
+
+new Tabs(
+ id: "report",
+ tabs: new[]
+ {
+ ("summary", "Summary", "High level numbers.
"),
+ ("detail", "Detail", tableHtml),
+ })
+```
+
+### Multiple independent tab groups
+
+```csharp
+new Tabs(id: "tabs-a", tabs: setA)
+new Tabs(id: "tabs-b", tabs: setB)
+```
+
+The `id` scopes JS initialization — each Tabs instance is independent.
+
+---
+
+## Tips and tricks
+
+- The `Id` of each tab tuple is used as the `data-tab` attribute — keep it URL-safe and unique within the instance.
+- The first tab is always activated on page load regardless of which tab was active before navigation.
+- Tab `Content` is raw HTML — HTML-encode any user-supplied values.
+- For lazy-loaded tab content, place HTMX attributes in the `Content` string and use `hx-trigger="revealed"` to load content when the panel becomes visible.
+- Tabs do not push to the URL hash by default. If you need deep-linkable tabs, listen to the `click` event on `.tabs-trigger` elements and update `location.hash`.
+- Tabs do not push to the URL hash by default. If you need deep-linkable tabs, listen to the `click` event on `.tabs-trigger` elements and update `location.hash`.
+
+---
+
+## Complete page example
+
+**`Templates/ProfileSettingsPage.htmx`**
+```html
+
+
Profile settings
+ $$SettingsTabs$$
+
+```
+
+**`Templates/ProfileSettingsPage.htmx.cs`**
+```csharp
+namespace Htmx.ApiDemo.Templates;
+
+public sealed class ProfileSettingsPage : ProfileSettingsPageBase
+{
+ private readonly IHtmxComponent _tabs;
+
+ public ProfileSettingsPage(ApplicationUser user, IAntiforgery af, HttpContext ctx)
+ {
+ var tokens = af.GetAndStoreTokens(ctx);
+ var afHtml = $""" """;
+
+ // Build each tab's content as raw HTML strings rendered into the Tabs component
+ var generalContent = $"""
+
+ """;
+
+ var securityContent = $"""
+
+ """;
+
+ _tabs = new Components.Tabs(
+ defaultValue: "general",
+ tabs: new[]
+ {
+ new TabItem(Value: "general", Label: "General", Content: generalContent),
+ new TabItem(Value: "security", Label: "Security", Content: securityContent),
+ });
+ }
+
+ protected override void RenderSettingsTabs(HtmxRenderContext ctx) => _tabs.Render(ctx.Next());
+}
+```
+
+**GET handler**
+```csharp
+[Handler]
+[MapGet("/profile")]
+public static partial class GetProfileSettingsHandler
+{
+ public record Query();
+ private static async Task HandleAsync(
+ Query _, HttpContext ctx, MongoDbService db, IAntiforgery af, CancellationToken ct)
+ {
+ var user = await db.GetCurrentUserAsync(ctx, ct);
+ return await ctx.WriteHtmxPage(
+ new ProfileSettingsPage(user, af, ctx), title: "Profile settings");
+ }
+}
+```
diff --git a/docs/Components/Textarea.md b/docs/Components/Textarea.md
new file mode 100644
index 0000000..da195d5
--- /dev/null
+++ b/docs/Components/Textarea.md
@@ -0,0 +1,229 @@
+# Textarea
+
+A styled multi-line text input with optional label, description, default value, and HTMX attributes.
+
+---
+
+## HTML structure
+
+```
+div.flex.flex-col.gap-1.5
+ label[for={id}].text-sm.font-medium ← omitted when label is empty
+ {label}
+ textarea[id, name, placeholder, rows, class, $$HxAttrs$$]
+ {defaultValue}
+ p.text-sm.text-muted-foreground ← omitted when description is empty
+ {description}
+```
+
+---
+
+## CSS mechanics
+
+| Class | Effect |
+|---|---|
+| `flex min-h-[80px] w-full rounded-md border border-input bg-background` | Full-width field with minimum height |
+| `px-3 py-2 text-sm` | Inner padding and text size |
+| `focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring` | Keyboard focus ring |
+| `disabled:cursor-not-allowed disabled:opacity-50` | Disabled state |
+| `placeholder:text-muted-foreground` | Muted placeholder text |
+| `resize-y` | Allows vertical resize only |
+
+---
+
+## Constructor signature
+
+```csharp
+public Textarea(
+ string id,
+ string name = "",
+ string placeholder = "",
+ string label = "",
+ string description = "",
+ string defaultValue = "",
+ string extraClasses = "",
+ string hxAttrs = "",
+ int rows = 3)
+```
+
+| Parameter | Description |
+|---|---|
+| `id` | Element id and label `for` target |
+| `name` | Form field name |
+| `placeholder` | Placeholder text |
+| `label` | Optional visible label |
+| `description` | Optional helper text below the field |
+| `defaultValue` | Pre-filled content of the textarea |
+| `extraClasses` | Additional Tailwind classes on the textarea |
+| `hxAttrs` | Verbatim HTMX / data attributes |
+| `rows` | Number of visible rows (default: 3) |
+
+---
+
+## Usage examples
+
+### Comment field
+
+```csharp
+new Textarea(
+ id: "comment",
+ name: "comment",
+ placeholder: "Write a comment…",
+ label: "Comment",
+ rows: 5)
+```
+
+### Bio field with default value
+
+```csharp
+new Textarea(
+ id: "bio",
+ name: "bio",
+ label: "Bio",
+ description: "Tell us about yourself (max 280 characters)",
+ defaultValue: user.Bio ?? "")
+```
+
+### Auto-expand with HTMX
+
+```csharp
+new Textarea(
+ id: "notes",
+ name: "notes",
+ label: "Notes",
+ rows: 3,
+ hxAttrs: """oninput="this.style.height=''; this.style.height=this.scrollHeight+'px'"""")
+```
+
+### Auto-save on input
+
+```csharp
+new Textarea(
+ id: "draft",
+ name: "content",
+ label: "Draft",
+ hxAttrs: """hx-post="/drafts/save" hx-trigger="keyup changed delay:500ms" hx-include="[name='content']"""")
+```
+
+### Reading in a form handler
+
+```csharp
+public record Command([property: FromForm] string Comment);
+
+// command.Comment contains the textarea value
+```
+
+---
+
+## Tips and tricks
+
+- HTML-encode the `defaultValue` if it contains user-supplied content — it is placed directly inside the `