Files
Htmx/docs/03-creating-a-component.md
T
2026-05-04 19:57:48 +05:00

7.1 KiB

Creating a New Component

Components are the reusable building blocks of the UI. They follow the same .htmx + .htmx.cs pair pattern as pages, but they live in Templates/Components/, implement IHtmxComponent, and are never responsible for HTTP routing.

The three component patterns

All existing components fall into one of three shapes. Pick the one that fits what you are building.


Pattern A — Simple slot component

Use this when every piece of output is a plain string set from outside.

<!-- 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;

    // Compute the final class string once in the constructor,
    // encode to UTF-8 bytes, never allocate again during render
    public Badge(string label, string variant = "default")
    {
        _labelData = label.ToUtf8Bytes();

        var variantClass = 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 {variantClass}"
                           .ToUtf8Bytes();
    }

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

Pattern B — Conditionally built sections

Use this when parts of the template are optional (e.g. a card header that only renders when a title is provided). Build the HTML string in the constructor and store as bytes; leave the byte array empty [] when not needed — WriteUtf8 on an empty span is a no-op.

<!-- Templates/Components/Card.htmx -->
<div class="rounded-lg border border-border bg-card text-card-foreground 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();

        // Header is only rendered when a title or description is supplied
        _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 — Component slots (embedding other components)

Use this when a slot should itself be rendered by another IHtmxComponent. Store the sub-component as a property and call component.Render(context) from the override.

<!-- Templates/Components/MyWrapper.htmx -->
<div class="wrapper p-4">
  $$Inner$$
</div>
// Templates/Components/MyWrapper.htmx.cs
namespace Htmx.ApiDemo.Templates.Components;

public sealed class MyWrapper : MyWrapperBase
{
    private readonly IHtmxComponent _inner;

    public MyWrapper(IHtmxComponent inner)
    {
        _inner = inner;
    }

    // Pass context.Next() so the recursion depth counter increments;
    // the runtime throws if nesting exceeds 512 levels
    protected override void RenderInner(HtmxRenderContext ctx)
        => _inner.Render(ctx.Next());
}

The depth guard (context.Next()) is automatically enforced by the infrastructure generated in HtmxInfrastructure.g.cs. You do not need to check it yourself.


Embedding a component in a page

Once a component implements IHtmxComponent, use it from a page's code-behind by assigning an instance to an IHtmxComponent property and delegating Render:

// inside MyPage.htmx.cs
public IHtmxComponent MyBadge { get; }

public MyPage(...)
{
    MyBadge = new Badge("New", variant: "secondary");
}

protected override void RenderMyBadge(HtmxRenderContext ctx)
    => MyBadge.Render(ctx.Next());

The corresponding slot in MyPage.htmx:

<div class="flex gap-2">
  <span>Status:</span>
  $$MyBadge$$
</div>

File naming and namespace rules

File location Generated 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. Keep components in Templates/Components/ so they land in the right namespace and stay separate from page templates.


HTML user content safety

The WriteUtf8 method writes raw bytes directly to the response. It does not HTML-encode.

  • Static strings you write in the constructor are trusted — you control them.
  • Any value that comes from user input (e.g. a form field, a database string) must be HTML-encoded before calling ToUtf8Bytes().
// 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