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.
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.
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:
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.
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.
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.
[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.