Theme:

2024: the year I fell in love with Tag Helpers

Earlier this year I was toying around with several other frameworks including Svelte, Astro, Laravel, and more. I grew to appreciate how expressive other templating languages are, with the ability to create custom elements and directives that resulted in beautiful human-readable markup.

I soon realized I could achieve the same thing in dot net using something that I had always looked down upon: tag helpers.

In this article I want to share with you my new-found love for tag helpers, and demonstrate how I use them to create expressive UIs in razor. If your the type who prefers to look at code, I threw together a quick repo to demonstrate some of the examples, view the repo on GitHub.

Before diving in, I want to briefly talk about Atomic Design, because it helps illustrate the importance of tag helpers and where they fit in a large maintainable code base.

What is Atomic Design

Atomic Design is a methodology for building user interfaces that takes cues from chemistry, with the smallest pieces called atoms combing to create slightly larger pieces called molecules, which are then combined to create even larger pieces called organisms, and so on. This modular approach helps teams scale design systems while preventing UI drift and duplication.

Atomic Design Concept

Credit: Brad Frost

Atomic Design in Umbraco

Now that we understand what Atomic Design is, let's look at how we can make it work in Umbraco. 

Atoms

To start with, we need something to represent atoms. Here's a list of requirements for an atom:

  • It should have a single responsibility
  • It should clearly communicate what it does
  • It should be possible to combine them into larger units (aka molecules)
  • It should be easy to customize them in edge cases if needed

There is only really one tool for the job here; tag helpers. View components and partials are too clunky, they are great at encapsulating larger chunks of a UI, but do not offer enough granularity to provide all of the requirements above.


<ui-carousel>
    <ui-carousel-container class="gap-x-1 h-full">
        @foreach (var (item, _) in carouselItems)
        {
            <ui-carousel-item class="basis-full relative h-full">
                <ui-image media="@(item)"
                          crops="@(new List<ImageCrop> { new(640, 360), new(1280, 720) })"
                          sizes="(min-width: 640px) 640px, 100vw"
                          class="absolute inset-0 w-full h-full object-cover"/>
            </ui-carousel-item>
        }
    </ui-carousel-container>
</ui-carousel>

Tag helpers make the perfect atoms because you can create discrete custom elements that can be combined to create larger portions of the UI

My biggest gripe with tag helpers (and the reason I avoided using them for so long) is they move  templating concerns away from the actual templates. That feels wrong to me, but as you'll see - in reality you end up writing very little HTML inside a tag helper, when done right. 

Check out the following Carousel implementation as an example, there's only a few lines of raw HTML in the whole file, most of the code is just unpacking props and converting it to JSON that is passed to a JS plugin. Better to have that in a tag helper than cluttering up your views in my opinion!



#region Components
[HtmlTargetElement("ui-carousel")]
public class CarouselRootTagHelper : ComponentTagHelper
{
    
    [HtmlAttributeName("drag-free")]
    public bool DragFree { get; set; } = false;
    
    [HtmlAttributeName("loop")]
    public bool Loop { get; set; } = false;
    //  More options...
    
    private const string BaseClasses = "overflow-hidden";

    public CarouselRootTagHelper(TwMerge twMerge) : base(twMerge)
    {
    }
    
    public override void Process(TagHelperContext context, TagHelperOutput output)
    {
        output.TagName = "div";

        var options = GetOptions();
        var jsonOptions = JsonSerializer.Serialize(options, new JsonSerializerOptions
        {
            PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
            DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
        });
        
        output.Attributes.Add("x-data", $"carousel({jsonOptions})");
        output.Attributes.Add("role", "region");
        output.Attributes.Add("aria-roledescription", "carousel");
        
        var mergedClasses = Tailwind.Merge(BaseClasses, context.GetExistingClasses());
        output.AddClasses(mergedClasses);
    }
    
    
    private CarouselOptions GetOptions()
    {
        return new CarouselOptions
        {
            // ... map tag helper props to DTO
        };
    }
}

[HtmlTargetElement("ui-carousel-container")]
public class CarouselContainerTagHelper : ComponentTagHelper
{
    private const string BaseClasses = "flex";

    public CarouselContainerTagHelper(TwMerge twMerge) : base(twMerge)
    {
    }
    
    public override void Process(TagHelperContext context, TagHelperOutput output)
    {
        output.TagName = "ul";

        var mergedClasses = Tailwind.Merge(BaseClasses, context.GetExistingClasses());
        output.AddClasses(mergedClasses);
        output.Attributes.Add("x-ref", "container");
    }

}

[HtmlTargetElement("ui-carousel-item")]
public class CarouselItemTagHelper : ComponentTagHelper
{
    private const string BaseClasses = "min-w-0 shrink-0 grow-0 basis-full";

    public CarouselItemTagHelper(TwMerge twMerge) : base(twMerge)
    {
    }
    
    public override void Process(TagHelperContext context, TagHelperOutput output)
    {
        output.TagName = "li";
        output.Attributes.Add("role", "group");
        output.Attributes.Add("aria-roledescription", "slide");
        
        var mergedClasses = Tailwind.Merge(BaseClasses, context.GetExistingClasses());
        output.AddClasses(mergedClasses);
    }
}

#endregion
#region Directives

[HtmlTargetElement(Attributes = "onclick:carousel:scroll-next")]
public class CarouselOnClickScrollNextTagHelper : TagHelper
{
    [HtmlAttributeName("onclick:carousel:scroll-next")]
    public bool ScrollNext { get; set; }

    public override void Process(TagHelperContext context, TagHelperOutput output)
    {
        output.Attributes.Add("x-on:click", "carousel.scrollNext()");
    }
}

[HtmlTargetElement(Attributes = "onclick:carousel:scroll-prev")]
public class CarouselOnClickScrollPrevTagHelper : TagHelper
{
    [HtmlAttributeName("onclick:carousel:scroll-prev")]
    public bool ScrollPrev { get; set; }

    public override void Process(TagHelperContext context, TagHelperOutput output)
    {
        output.Attributes.Add("x-on:click", "carousel.scrollPrev()");
    }
}

// ...more directives to handle keyup handlers, etc
#endregion


Another potential headache with this approach (at least for me, since I use Tailwind) is the need to rebuild whenever one of the base CSS classes changes. However, in practice that doesn't happen very often, and when it does, you only have to change one file.

In fact, if you are using Tailwind it's possible to add an 'escape hatch' for those edge cases where you need to do something slightly different, thanks to the TailwindMerge.Net library. Let's say you have a button that usually has a black background, but in one case you need it to be red, just pass in the class as you normally would, intercept it in the tag helper, and let TailwindMerge.Net handle the rest:


<ui-button variant="Link"
           href="/some-url"
           class="bg-red text-xl">
    @(Umbraco.Translate("Event.ViewArtist", "View artist"))
</ui-button>

It's useful to add a way to override styles in 'edge cases'


[HtmlTargetElement("ui-button")]
public class ButtonTagHelper : ComponentTagHelper
{
    [HtmlAttributeName("size")]
    public ButtonSize Size { get; set; } = ButtonSize.Small;
    
    [HtmlAttributeName("variant")]
    public ButtonVariant Variant { get; set; } = ButtonVariant.Secondary;

    [HtmlAttributeName("tag")]
    public string Tag { get; set; } = "button";
    
    private const string BaseClasses =
        "button ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap rounded-full font-medium transition-colors duration-400 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50";

    public ButtonTagHelper(TwMerge twMerge) : base(twMerge)
    {
    }
    
    public override void Process(TagHelperContext context, TagHelperOutput output)
    {
        output.TagName = output.Attributes.ContainsName("href") ? "a" : Tag;

        output.AddClasses(Tailwind.Merge(
            BaseClasses, 
            ButtonSizeUtils.GetButtonSizeClasses(Size),
            ButtonVariantUtils.GetButtonVariantClasses(Variant),
            context.GetExistingClasses()));
        
        if(!context.AllAttributes.ContainsName("type") && !context.AllAttributes.ContainsName("href"))
            output.Attributes.Add("type", "button");
    }
}

// GetExistingClass extension...


public static class TagHelperContextExtensions
{
    public static string GetExistingClasses(this TagHelperContext context)
    {
        return context.AllAttributes.TryGetAttribute("class", out var tagAttribute)
            ? tagAttribute.Value?.ToString() ?? string.Empty 
            : string.Empty;
    }
}

TailwindMerge.NET allows you to swap out matching Tailwind classes at runtime.

Molecules & organisms

As you move up the atomic scale towards molecules and organisms, it makes sense to encapsulate larger pieces of UI into partials or view components.

My personal preference is to use partials where possible, but as soon as the inputs increase beyond a simple model, it's time to switch to a view component. Another guideline I try to stick to; if the component needs to fetch it's own data, use a view component.

Pages & templates

Finally, at the top of the scale, pages and templates translate directly into Umbraco templates and layouts.

And that's basically Atomic Design in Umbraco, in a nutshell. 

I see Tag Helpers, they're everywhere

Once you understand the value and flexibility of TagHelpers you'll come up with all kinds of novel uses for them, here's a few more examples of my own.

Wrapping JS libraries

I use AlpineJS and HTMX in my projects. They work with existing HTML, just sprinkle them into your project where it makes sense and let the magic happen. That also applies to tag helpers, and in fact Alpine + tag helpers are a great way to wrap JavaScript components up as declarative HTML.

As you saw above, I use them to wrap the Embla Carousel library. I wrapped the vanilla JS package in a custom Alpine component, and then wrapped that up in a tag helper. That's a lot of wrapping even for Christmas, but it's worth it; it's a dream to use, and it's reusable across all my projects.


<ui-carousel drag-free="true"
             loop="true"
             start-index="5"
             ...more options!>
    
</ui-carousel>

Make complex components declarative and easy to work with

Mixins

Sometimes you just want to sprinkle additional behaviors onto an existing element. Sticking with the carousel for a moment longer, it's common to have navigation buttons. No need to create a custom element, just sprinkle an onclick handler onto any existing element. 


<ui:button onclick:carousel:scroll-next>
    <ui:icon icon="chevron-right"/>
</ui:button>

Tag helpers are a great way to share behaviour across elements



[HtmlTargetElement(Attributes = "onclick:carousel:scroll-next")]
public class CarouselOnClickScrollNextTagHelper : TagHelper
{
    //  We don't actually use this property, but we need something to bind [HtmlTargetElement(Attribute)] to
    //  The attribtue can be added to any HTML element without needing to provide a value,
    //      the mere presence of the attribute is enough to trigger this tag  helper.
    //
    // <div onclick:carousel:scroll-next> </div>
    // <button onclick:carousel:scroll-next> </button>
    [HtmlAttributeName("onclick:carousel:scroll-next")]
    public bool ScrollNext { get; set; }

    public override void Process(TagHelperContext context, TagHelperOutput output)
    {
        output.Attributes.Add("x-on:click", "carousel.scrollNext()");
    }
}

The mixin doesn't care what element it's added to

Conditionals

I created tag helpers for some of my most frequent conditional rendering statements. I'm not sure if these prevent code inside the element from executing or whether they just surpress the output, so there may be a slight performance hit to this approach, but in any case they reduce nesting and make the markup a little easier on the eye.


<div if="true"

     if:true="@(someBoolean)"
     if:false="@(someBoolean)"

     if:null="@(someNullable)"
     if:not-null="@(someNullable)"

     if:string:empty="@(someString)"
     if:string:not-empty="@(someString)"

     if:html:empty="@(someEncodedHtmlString)"
     if:html:not-empty="@(someEncodedHtmlString)"

     if:enumerable:empty="@(someEnumerable)"
     if:enumerable:not-empty="@(someEnumerable)">
    
    <p>
        Some conditional content
    </p>
</div>


[HtmlTargetElement(Attributes = "if")]
public class IfTagHelper : TagHelper
{
    [HtmlAttributeName("if")]
    public bool Predicate { get; set; }

    public override void Process(TagHelperContext context, TagHelperOutput output)
    {
        if (Predicate) return;
        output.SuppressOutput();
    }
}

[HtmlTargetElement(Attributes = "if:true")]
public class IfTrueTagHelper : TagHelper
{
    [HtmlAttributeName("if:true")]
    public bool Predicate { get; set; }

    public override void Process(TagHelperContext context, TagHelperOutput output)
    {
        if (Predicate) return;
        output.SuppressOutput();
    }
}

[HtmlTargetElement(Attributes = "if:false")]
public class IfFalseTagHelper : TagHelper
{
    [HtmlAttributeName("if:false")]
    public bool Predicate { get; set; }

    public override void Process(TagHelperContext context, TagHelperOutput output)
    {
        if (!Predicate) return;
        output.SuppressOutput();
    }
}

[HtmlTargetElement(Attributes = "if:null")]
public class IfNullTagHelper : TagHelper
{
    [HtmlAttributeName("if:null")]
    public object? Value { get; set; }

    public override void Process(TagHelperContext context, TagHelperOutput output)
    {
        if (Value is null) return;
        output.SuppressOutput();
    }
}

[HtmlTargetElement(Attributes = "if:not-null")]
public class IfNotNullTagHelper : TagHelper
{
    [HtmlAttributeName("if:not-null")]
    public object? Value { get; set; }

    public override void Process(TagHelperContext context, TagHelperOutput output)
    {
        if (Value is not null) return;
        output.SuppressOutput();
    }
}

[HtmlTargetElement(Attributes = "if:html:empty")]
public class IfHtmlNotEmptyTagHelper : TagHelper
{
    [HtmlAttributeName("if:html:empty")] 
    public IHtmlEncodedString? HtmlEncodedString { get; set; } = null;
    
    public override void Process(TagHelperContext context, TagHelperOutput output)
    {
        if (string.IsNullOrEmpty(HtmlEncodedString?.ToString())) return;
        output.SuppressOutput();
    }
}

[HtmlTargetElement(Attributes = "if:html:not-empty")]
public class IfHtmlNotEmptyTagHelper : TagHelper
{
    [HtmlAttributeName("if:html:not-empty")] 
    public IHtmlEncodedString? HtmlEncodedString { get; set; } = null;
    
    public override void Process(TagHelperContext context, TagHelperOutput output)
    {
        if (!string.IsNullOrEmpty(HtmlEncodedString?.ToString())) return;
        output.SuppressOutput();
    }
}

[HtmlTargetElement(Attributes = "if:enumerable:empty")]
public class IfEnumerableEmptyTagHelper : TagHelper
{
    [HtmlAttributeName("if:enumerable:empty")]
    public IEnumerable<object>? Enumerable { get; set; }

    public override void Process(TagHelperContext context, TagHelperOutput output)
    {
        if (Enumerable is not null && Enumerable.Any())
            output.SuppressOutput();
    }
}

[HtmlTargetElement(Attributes = "if:enumerable:not-empty")]
public class IfEnumerableNotEmptyTagHelper : TagHelper
{
    [HtmlAttributeName("if:enumerable:not-empty")]
    public IEnumerable<object>? Enumerable { get; set; }

    public override void Process(TagHelperContext context, TagHelperOutput output)
    {
        if (Enumerable is null || !Enumerable.Any())
            output.SuppressOutput();
    }
}

[HtmlTargetElement(Attributes = "if:string:empty")]
public class IfStringTagHelper : TagHelper
{
    [HtmlAttributeName("if:string:empty")] 
    public string? StringValue { get; set; } = null;

    public override void Process(TagHelperContext context, TagHelperOutput output)
    {
        if (string.IsNullOrEmpty(StringValue)) return;
        output.SuppressOutput();
    }
}

[HtmlTargetElement(Attributes = "if:string:not-empty")]
public class IfStringTagHelper : TagHelper
{
    [HtmlAttributeName("if:string:not-empty")] 
    public string? StringValue { get; set; } = null;

    public override void Process(TagHelperContext context, TagHelperOutput output)
    {
        if (!string.IsNullOrEmpty(StringValue)) return;
        output.SuppressOutput();
    }
}

Unpacking Block Settings

I often use Tag Helpers to unpack block settings from Umbraco and map them to the appropriate classes or styles on the HTML element.


<section id="@(id)"
         appearance:background="@(settings)" 
         appearance:margin="@(settings)" 
         appearance:padding="@(settings)">
    <div class="max-w-screen-xl mx-auto px-12">
        @await Html.GetPreviewBlockGridItemAreasHtmlAsync(Model)
    </div>
</section>

Tag helpers can help to unpack block settings


[HtmlTargetElement(Attributes = "appearance:margin")]
public class AppearanceMarginTagHelper : TagHelper
{
    public override int Order { get; } = 10;

    [HtmlAttributeName("appearance:margin")]
    public ISpacingComposition? Spacing { get; set; }

    private readonly TwMerge _twMerge;
    
    public AppearanceMarginTagHelper(TwMerge twMerge)
    {
        _twMerge = twMerge;
    }
    
    public override void Process(TagHelperContext context, TagHelperOutput output)
    {
        var existingClasses = output.Attributes.TryGetAttribute("class", out var tagAttribute)
            ? tagAttribute.Value.ToString()
            : string.Empty;

        var mergedClasses = _twMerge.Merge(
            existingClasses ?? string.Empty,
            SpacingUtils.GetMarginTopClasses(Spacing?.MarginTop),
            SpacingUtils.GetMarginBottomClasses(Spacing?.MarginBottom));
        
        output.SetClasses(mergedClasses);
    }
}

Final thoughts

As you can see, I’m pretty deep into this approach now. Having used it on my last 3 projects it’s proving to be an incredibly nice way to author UIs. I am still learning and adapting it on each project, but it feels like a good approach, which is why I wanted to share it with you. Here's a quick run down of some of the benefits I've noticed:

  • I am able to re-use most components across multiple projects
  • I am writing less boilerplate (anyone who uses Tailwind knows it can get out of hand quickly)
  • My templates are easier to read, it's immediately obvious what a chunk of UI is doing
  • There's less cognitive load when switching between projects
  • Bugs are easier to fix, and changes are easier to make
  • I have a great deal of flexibility in how I compose sections

There is a part of me that feels a bit foolish for not recognizing the value of tag helpers earlier, but it's also nice to find new value in existing tools, and I think that's the take away here. I’m keen to see what novel things other people are doing with tag helpers.

Anyway, I hope you found this useful. Thanks for reading, and happy coding!

As a reminder, you can view some code examples in this repo; view repo on GitHub.


Philip Hayton
Philip Hayton