Files
Htmx/docs/03-creating-a-component.md
T
2026-05-05 23:55:26 +05:00

8.8 KiB

Creating a New Component

A component is a reusable stamp. You design the stamp once (the .htmx template + .htmx.cs class), and then press it anywhere you need that piece of UI — on multiple pages, inside other components, even multiple times on the same page.

Components are identical in structure to pages, with two key differences:

  1. They live in Templates/Components/ instead of Templates/
  2. They are never responsible for HTTP routing — they just render HTML

What you want to achieve

By the end of this guide you will be able to build any reusable UI piece — a styled label, a card, a form field, or a wrapper that holds other components — and drop it anywhere on a page.


The three patterns

All components fit one of three shapes. Pick the one that matches what you are building.


Pattern A — A simple label or display element

Use this when the component just renders a styled string. It is the simplest case.

Goal: a coloured status badge you can reuse in tables, cards, and headers.

<!-- Templates/Components/Badge.htmx -->
<span class="$$Classes$$">$$Label$$</span>
// Templates/Components/Badge.htmx.cs
namespace Htmx.ApiDemo.Templates.Components;

public sealed class Badge : BadgeBase
{
    private readonly byte[] _labelData;
    private readonly byte[] _classesData;

    public Badge(string label, string variant = "default")
    {
        _labelData = label.ToUtf8Bytes();

        var variantClasses = variant switch
        {
            "secondary"   => "bg-secondary text-secondary-foreground",
            "destructive" => "bg-destructive text-destructive-foreground",
            "outline"     => "border border-border text-foreground",
            _             => "bg-primary text-primary-foreground",
        };

        _classesData = $"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold {variantClasses}"
                           .ToUtf8Bytes();
    }

    protected override void RenderLabel(HtmxRenderContext ctx)   => ctx.Writer.WriteUtf8(_labelData);
    protected override void RenderClasses(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_classesData);
}

The key principle: all computation happens in the constructor. By the time RenderLabel is called during a request, it is just writing pre-computed bytes — no string formatting, no allocations.


Pattern B — A container with optional sections

Use this when parts of the component are optional — for example a card that shows a header only when a title is provided.

Goal: a card that always shows its body, but optionally shows a header and a footer.

<!-- Templates/Components/Card.htmx -->
<div class="rounded-lg border border-border bg-card shadow-sm $$ExtraClasses$$">
  $$Header$$
  <div class="p-6 pt-0">$$Content$$</div>
  $$Footer$$
</div>
// Templates/Components/Card.htmx.cs
namespace Htmx.ApiDemo.Templates.Components;

public sealed class Card : CardBase
{
    private readonly byte[] _extraClassesData;
    private readonly byte[] _headerData;
    private readonly byte[] _contentData;
    private readonly byte[] _footerData;

    public Card(
        string content,
        string title        = "",
        string description  = "",
        string footer       = "",
        string extraClasses = "")
    {
        _extraClassesData = extraClasses.ToUtf8Bytes();
        _contentData      = content.ToUtf8Bytes();

        // Build header HTML in the constructor. If there's no title/description,
        // store an empty array — writing empty bytes is a no-op.
        _headerData = (string.IsNullOrEmpty(title) && string.IsNullOrEmpty(description))
            ? []
            : BuildHeader(title, description);

        _footerData = string.IsNullOrEmpty(footer)
            ? []
            : $"""<div class="flex items-center p-6 pt-0">{footer}</div>""".ToUtf8Bytes();
    }

    private static byte[] BuildHeader(string title, string description)
    {
        var sb = new System.Text.StringBuilder();
        sb.Append("""<div class="flex flex-col space-y-1.5 p-6">""");
        if (!string.IsNullOrEmpty(title))
            sb.Append($"""<h3 class="text-2xl font-semibold leading-none tracking-tight">{title}</h3>""");
        if (!string.IsNullOrEmpty(description))
            sb.Append($"""<p class="text-sm text-muted-foreground">{description}</p>""");
        sb.Append("</div>");
        return sb.ToString().ToUtf8Bytes();
    }

    protected override void RenderExtraClasses(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_extraClassesData);
    protected override void RenderHeader(HtmxRenderContext ctx)       => ctx.Writer.WriteUtf8(_headerData);
    protected override void RenderContent(HtmxRenderContext ctx)      => ctx.Writer.WriteUtf8(_contentData);
    protected override void RenderFooter(HtmxRenderContext ctx)       => ctx.Writer.WriteUtf8(_footerData);
}

Pattern C — A wrapper that holds other components

Use this when a slot should be filled by another component rather than a string.

Goal: a tooltip wrapper — the trigger is any component, and the tooltip text floats above it on hover.

<!-- Templates/Components/Tooltip.htmx -->
<span class="relative inline-flex items-center group">
  $$Trigger$$
  <span class="absolute bottom-full mb-1.5 ... opacity-0 group-hover:opacity-100">$$Text$$</span>
</span>
// Templates/Components/Tooltip.htmx.cs
namespace Htmx.ApiDemo.Templates.Components;

public sealed class Tooltip : TooltipBase
{
    private readonly IHtmxComponent _trigger;
    private readonly byte[] _textData;

    public Tooltip(string text, IHtmxComponent trigger)
    {
        _textData = text.ToUtf8Bytes();
        _trigger  = trigger;
    }

    protected override void RenderText(HtmxRenderContext ctx)
        => ctx.Writer.WriteUtf8(_textData);

    // ctx.Next() increments the nesting depth counter.
    // The runtime throws if nesting exceeds 512 levels — this is the guard against infinite loops.
    protected override void RenderTrigger(HtmxRenderContext ctx)
        => _trigger.Render(ctx.Next());
}

Using a component inside a page

Once you have a component, use it from a page's code-behind. The page stores the component as a field and delegates Render from its slot override:

// MyPage.htmx.cs
public sealed class MyPage : MyPageBase
{
    private readonly byte[] _headingData;
    private readonly IHtmxComponent _statusBadge;

    public MyPage(string heading, string status)
    {
        _headingData = heading.ToUtf8Bytes();
        _statusBadge = new Badge(status, variant: "secondary");
    }

    protected override void RenderHeading(HtmxRenderContext ctx)
        => ctx.Writer.WriteUtf8(_headingData);

    protected override void RenderStatusBadge(HtmxRenderContext ctx)
        => _statusBadge.Render(ctx.Next());
}

Template:

<!-- MyPage.htmx -->
<div class="flex items-center gap-3">
  <h1>$$Heading$$</h1>
  $$StatusBadge$$
</div>

A note on HTML safety

WriteUtf8 writes raw bytes directly to the HTTP response. It does not HTML-encode anything.

  • Strings you write in the constructor that come from your own code are fine — you control them.
  • Any value that comes from user input (a form field, a database value, a query parameter) must be HTML-encoded before calling ToUtf8Bytes():
// Safe — encodes characters like < > " &
_nameData = System.Web.HttpUtility.HtmlEncode(userInput).ToUtf8Bytes();

Skipping this step is a cross-site scripting (XSS) vulnerability.


File location and namespace

File location C# namespace
Templates/Components/MyComp.htmx Htmx.ApiDemo.Templates.Components
Templates/MyPage.htmx Htmx.ApiDemo.Templates

The source generator derives the namespace from the folder path relative to the project root. Always keep components in Templates/Components/.


Checklist

  • .htmx template created in Templates/Components/ with $$PascalCase$$ slots
  • .htmx.cs class inherits the generated XxxBase class
  • All RenderXxx overrides implemented
  • Computation (string building, class selection) done in the constructor
  • User-provided strings HTML-encoded before ToUtf8Bytes()
  • Sub-component Render calls use ctx.Next() not bare ctx // Safe — user-supplied string is encoded first _displayNameData = System.Web.HttpUtility.HtmlEncode(userDisplayName).ToUtf8Bytes();

The existing `MainLayout` constructor demonstrates this for the user initials section.

---

## Checklist

- [ ] `MyComp.htmx` created in `Templates/Components/`
- [ ] `MyComp.htmx.cs` created with class inheriting `MyCompBase`
- [ ] All `$$Slot$$`s have a matching `RenderSlot` override
- [ ] User-supplied strings are HTML-encoded before `ToUtf8Bytes()`
- [ ] Build once to confirm the compiler catches any missing overrides