
If you’ve spent time working on a sizeable Blazor application, you’ll likely be familiar with the drift that tends to creep into typography over time. One page uses a <p> element with an inline font-size, another uses a <span> with a utility class, and a third has a form <label> that was copied and pasted from somewhere and doesn’t quite match the rest. Individually, none of these is necessarily a big problem (depending on your viewpoint). However, collectively, they result in an application that feels inconsistent and make a genuine design system difficult to enforce.
A reusable Text component solves this problem neatly. Rather than scattering font sizes, weights, and colours across markup wherever text needs to be rendered, the available typographic styles are centralised into a single component, along with a fixed set of variants. The tricky part is that text doesn’t always belong in the same HTML element; the text might need to be a <p> in one context and a <label> in a form context, for example.
In this post, I’ll walk through how to build a reusable Text component in Blazor that renders a consistent set of typography variants, while allowing the underlying HTML element to be customised.
The problem with a fixed element
The first seemingly obvious attempt at creating a Text component is to wrap a <span> and expose a Variant parameter to control its CSS class, as shown below.
<span class="@GetClass()">     @ChildContent </span> @code {     [Parameter]     public TextVariant Variant { get; set; }     [Parameter]     public RenderFragment? ChildContent { get; set; }     private string GetClass() => $"text text--{Variant.ToString().ToLower()}"; }
This works fine, until you need to use the component somewhere that a <span> isn’t appropriate. Form labels need a <label> element to remain accessible and associated with their input. Body copy often needs to be a <p> for correct semantics and spacing. A heading-style variant might need to render inside a <div> because it’s sitting alongside other block-level content. Hardcoding the element means you either end up with invalid or inaccessible markup, or you create several near-identical components (TextSpan, TextLabel, TextParagraph) that all do the same thing but with a different wrapper. Neither of these options is particularly appealing.
What we need instead is a single, unified component with fixed, consistent typography, while also allowing the consumer to specify the rendered element.
What we’ll build
We’re going to build a Blazor Text component that accepts a Variant parameter to control the typographic style (heading, body copy, label, etc.), an As parameter to control the HTML element that is rendered, and a Color parameter that maps to a handful of semantic colours that an application typically needs. The component will also support any additional attributes that are passed to it, such as id, class, or event handlers, and will splat these onto the rendered element.
Below are some examples of how the component can be used once it’s built.
<Text Variant=@TextVariant.Heading2>Account settings</Text> <Text Variant=@TextVariant.Body1 As=@TextElement.Paragraph>     Update your personal details below. </Text> <Text Variant=@TextVariant.Label1 As=@TextElement.Label Color=@TextColor.Muted>     Email address </Text>
Note that I recommend creating a separate Heading component that can dynamically render h1, h2, h3 tags, etc. for heading text, but once you’ve seen how the Text component is built, you’ll know how to implement this too.
Looking at the above code, you can see that the component is simple to use, yet flexible enough to support as many text variants and colours as needed, while providing control over the final markup by allowing us to specify the HTML element that is rendered.
Building the Text component
Since the element name that is rendered needs to change based on a parameter, we can’t just write markup directly in our .razor component file using standard Razor syntax. Razor doesn’t allow us to interpolate the name of an HTML tag. Instead, the component needs to build its render tree manually, using RenderTreeBuilder.
The markup and code
Below is the full listing for the Text.razor component.
@using Microsoft.AspNetCore.Components.Rendering @RenderContent @code {     [Parameter]     public TextVariant Variant { get; set; } = TextVariant.Body1;     [Parameter]     public TextElement As { get; set; } = TextElement.Span;     [Parameter]     public TextColor Color { get; set; } = TextColor.Inherit;     [Parameter]     public RenderFragment? ChildContent { get; set; }     [Parameter(CaptureUnmatchedValues = true)]     public IReadOnlyDictionary<string, object>? AdditionalAttributes { get; set; }     private RenderFragment RenderContent => RenderElement;     private string ElementName => As switch     {         TextElement.Div => "div",         TextElement.Label => "label",         TextElement.Paragraph => "p",         _ => "span"     };     private void RenderElement(RenderTreeBuilder builder)     {         builder.OpenElement(0, ElementName);         builder.AddMultipleAttributes(1, AdditionalAttributes);         builder.AddAttribute(2, "class", TypographyClass.Build(Variant, Color, AdditionalAttributes));         builder.AddContent(3, ChildContent);         builder.CloseElement();     } }
The component takes four parameters:
Variant– an enum controlling the typographic style, defaulting toBody1.As– an enum controlling the HTML element that is rendered, defaulting toSpan.Color– an enum for the available semantic text colours, defaulting toInherit.ChildContent– the text (or nested markup) to render inside the element.
The private ElementName property maps the As enum to the actual tag name, and the RenderElement method uses the OpenElement/CloseElement methods on the RenderTreeBuilder to construct the element dynamically. This is the real crux of the component; everything else is supporting logic around it.
Below are the TextVariant, TextElement, and TextColor enum definitions for reference.
public enum TextVariant {     Heading1,     Heading2,     Heading3,     Body1,     Body2,     Label1,     Label2,     Caption } public enum TextElement {     Span,     Div,     Label,     Paragraph } public enum TextColor {     Inherit,     Primary,     Secondary,     Success,     Warning,     Danger,     Muted }
You’ll need to adjust the enums to suit the application you are developing, and make sure that the values line up with your team’s design system if applicable.
Merging additional attributes
One detail that is worth calling out is the interaction between the AddMultipleAttributes call and the explicit AddAttribute call for the class attribute that follows it. The AdditionalAttributes parameter is configured to capture any unmatched parameters that are passed to the component, including class. This means that if a consumer writes <Text class="mb-4">, that value will end up in the dictionary. If we simply called AddMultipleAttributes and stopped there, a consumer-supplied class would work, but it would completely replace the typography classes that the component is meant to apply, since there’d be no way to combine the two.
To handle this scenario, the TypographyClass.Build method is responsible for generating the full class string, including any existing class value found in AdditionalAttributes, and the result is then applied via AddAttribute at a later sequence number. As part of Blazor’s diffing algorithm, if the same attribute name is set more than once on an element, the last value applied wins. This means that our combined class string, containing both the typography classes and the consumer’s own class, takes precedence over whatever AddMultipleAttributes initially set. As a result, consumers can freely add their own classes (or any other attribute) to a Text component without losing the built-in styling or clobbering it by accident.
The code for the TypographyClass helper is shown below for reference.
public static class TypographyClass {     public static string Build(         TextVariant variant,         TextColor color,         IReadOnlyDictionary<string, object>? additionalAttributes)     {         var classes = new List<string>         {             "text",             $"text--{variant.ToString().ToLowerInvariant()}"         };         if (color is not TextColor.Inherit)         {             classes.Add($"text--{color.ToString().ToLowerInvariant()}");         }         if (additionalAttributes is not null &&             additionalAttributes.TryGetValue("class", out var existingClass) &&             existingClass is string existingClassValue &&             !string.IsNullOrWhiteSpace(existingClassValue))         {             classes.Add(existingClassValue);         }         return string.Join(' ', classes);     } }
The Build method first builds a list of typography classes based on the supplied parameters, then adds any existing class attribute value if applicable, and lastly joins the class values together, returning them as a single string.
The CSS
The component generates predictable text--* classes, so the styling itself is straightforward, plain CSS. Below is a starting point that covers all the variant and colour values contained in the enums from further above; you will need to adjust these to match your own design tokens.
.text {     margin: 0;     font-family: inherit; } .text--heading1 {     font-size: 2.25rem;     font-weight: 700;     line-height: 1.2; } .text--heading2 {     font-size: 1.75rem;     font-weight: 700;     line-height: 1.25; } .text--heading3 {     font-size: 1.375rem;     font-weight: 600;     line-height: 1.3; } .text--body1 {     font-size: 1rem;     font-weight: 400;     line-height: 1.5; } .text--body2 {     font-size: 0.875rem;     font-weight: 400;     line-height: 1.5; } .text--label1 {     font-size: 0.875rem;     font-weight: 600;     line-height: 1.4; } .text--label2 {     font-size: 0.75rem;     font-weight: 600;     line-height: 1.4;     letter-spacing: 0.02em; } .text--caption {     font-size: 0.75rem;     font-weight: 400;     line-height: 1.4;     color: #6b7280; } .text--primary {     color: #2563eb; } .text--secondary {     color: #6b7280; } .text--success {     color: #16a34a; } .text--warning {     color: #d97706; } .text--danger {     color: #dc2626; } .text--muted {     color: #9ca3af; }
Since these are global rather than scoped classes, this stylesheet should live in your application’s shared CSS file (e.g. app.css or a separate typography.css file, for example) rather than as an isolated Text.razor.css file, since it needs to apply to whichever element the component happens to render as. This approach also allows other components such as Heading to refer to the available text variants and colours.
Using the component
With everything now in place, using the component is simple, and the rendered markup stays clean regardless of which element is chosen, as shown in the examples below.
<Text Variant=@TextVariant.Heading2 As=@TextElement.Div>     Account settings </Text> <Text Variant=@TextVariant.Body1 As=@TextElement.Paragraph>     Update your account details below. </Text> <Text Variant=@TextVariant.Label1 As=@TextElement.Label Color=@TextColor.Muted for="email">     Email address </Text> <input id="email" type="email" />
Notice that the for attribute on the last example is passed straight through via AdditionalAttributes, so the <label> remains correctly associated with its input. This provides the same result as if you’d written the <label> element by hand.
Below is the HTML markup rendered for the examples above.
<div class="text text--heading2">     Account settings </div> <p class="text text--body1">     Update your account details below. </p> <label for="email" class="text text--label1 text--muted">     Email address </label> <input id="email" type="email">
You can see that the element names match up with what we would expect, the class names are applied correctly, and the for attribute has been included.
The final word
A Text component like the one we’ve built keeps typography consistent across an application without forcing every piece of text into the same HTML element. The variant, colour, and element are all controlled independently, and the RenderTreeBuilder approach means there’s only one component to maintain.
While it may be a small addition to a component library, it tends to pay for itself quickly when your application grows, since it becomes the one place where typography decisions live.


Comments