Original Microsoft documentation: WebControl Class
Many Web Forms applications contain custom server controls that inherit from System.Web.UI.WebControls.WebControl and render HTML using HtmlTextWriter. These controls use the imperative rendering pattern:
- Override
TagKeyto set the outer HTML element - Override
AddAttributesToRender(HtmlTextWriter)to add attributes - Override
RenderContents(HtmlTextWriter)to write inner HTML
BlazorWebFormsComponents provides a WebControl base class with an HtmlTextWriter shim that lets this code run unchanged in Blazor.
- Change the
usingstatement:
// Before (Web Forms)
using System.Web.UI.WebControls;
// After (Blazor + BWFC)
using BlazorWebFormsComponents.CustomControls;-
Add
[Parameter]attributes to public properties (Blazor requirement) -
That's it. Your
RenderContents,TagKey, andAddAttributesToRendercode works unchanged.
using System.Web.UI.WebControls;
public class StatusBadge : WebControl
{
public string Status { get; set; }
public string Label { get; set; }
protected override HtmlTextWriterTag TagKey => HtmlTextWriterTag.Span;
protected override void AddAttributesToRender(HtmlTextWriter writer)
{
base.AddAttributesToRender(writer);
writer.AddAttribute("data-status", Status?.ToLowerInvariant() ?? "unknown");
}
protected override void RenderContents(HtmlTextWriter writer)
{
writer.RenderBeginTag(HtmlTextWriterTag.Strong);
writer.Write(Label ?? Status);
writer.RenderEndTag();
}
}using BlazorWebFormsComponents.CustomControls;
using Microsoft.AspNetCore.Components;
public class StatusBadge : WebControl
{
[Parameter]
public string Status { get; set; }
[Parameter]
public string Label { get; set; }
protected override HtmlTextWriterTag TagKey => HtmlTextWriterTag.Span;
protected override void AddAttributesToRender(HtmlTextWriter writer)
{
base.AddAttributesToRender(writer);
writer.AddAttribute("data-status", Status?.ToLowerInvariant() ?? "unknown");
}
protected override void RenderContents(HtmlTextWriter writer)
{
writer.RenderBeginTag(HtmlTextWriterTag.Strong);
writer.Write(Label ?? Status);
writer.RenderEndTag();
}
}Usage in a .razor page:
<StatusBadge Status="Active" Label="Online" CssClass="badge bg-success" />TagKey— outer HTML element selectionRenderContents(HtmlTextWriter)— imperative inner content renderingAddAttributesToRender(HtmlTextWriter)— custom attribute injectionRender(HtmlTextWriter)— full rendering overrideRenderBeginTag/RenderEndTag— outer tag customizationCssClass,ID,ToolTip,Enabled,Visible— inherited fromBaseStyledComponentHtmlTextWritermethods:Write,WriteLine,RenderBeginTag,RenderEndTag,AddAttribute,AddStyleAttributeHtmlTextWriterTagenum — all standard HTML elementsHtmlTextWriterAttributeenum — all standard HTML attributesHtmlTextWriterStyleenum — all standard CSS properties
ViewStatepersistence between renders (use component state instead)CreateChildControls()(useCompositeControlbase class or Razor templates)INamingContainerautomatic ID generation (use explicitIDparameter)- Server-side event postback from rendered HTML (use Blazor
@onclicketc.)
The BWFC WebControl class overrides Blazor's BuildRenderTree:
- Creates an
HtmlTextWriterinstance (backed byStringBuilder) - Calls
AddAttributesToRender(writer)— adds ID, class, style, tooltip, disabled - Calls
Render(writer)— which by default callsRenderBeginTag→RenderContents→RenderEndTag - Emits the captured HTML via
builder.AddMarkupContent(0, writer.GetHtml())
This means your imperative rendering code produces the same HTML output it did in Web Forms.
public class HelloLabel : WebControl
{
[Parameter]
public string Text { get; set; }
protected override void RenderContents(HtmlTextWriter writer)
{
writer.Write(Text);
}
}Renders: <span>Hello World</span>
public class InfoCard : WebControl
{
[Parameter]
public string Title { get; set; }
[Parameter]
public string Body { get; set; }
protected override HtmlTextWriterTag TagKey => HtmlTextWriterTag.Div;
protected override void RenderContents(HtmlTextWriter writer)
{
writer.AddAttribute(HtmlTextWriterAttribute.Class, "card-header");
writer.RenderBeginTag(HtmlTextWriterTag.Div);
writer.RenderBeginTag(HtmlTextWriterTag.H3);
writer.Write(Title);
writer.RenderEndTag(); // h3
writer.RenderEndTag(); // header div
writer.AddAttribute(HtmlTextWriterAttribute.Class, "card-body");
writer.RenderBeginTag(HtmlTextWriterTag.Div);
writer.Write(Body);
writer.RenderEndTag(); // body div
}
}public class CustomButton : WebControl
{
[Parameter]
public string Text { get; set; }
[Parameter]
public string ButtonType { get; set; } = "button";
protected override void Render(HtmlTextWriter writer)
{
writer.AddAttribute(HtmlTextWriterAttribute.Type, ButtonType);
writer.RenderBeginTag(HtmlTextWriterTag.Button);
writer.Write(Text);
writer.RenderEndTag();
}
}The migration CLI automatically:
- Converts
.ascx.csfiles withWebControlbase classes → preserves the base class - Adds
using BlazorWebFormsComponents.CustomControls; - Injects
@inherits WebControlinto the.razorfile - Adds
[Parameter]to public properties (viaParameterAttributeTransform)
No manual editing of rendering code is required.
!!! tip
If your control uses CreateChildControls(), consider the CompositeControl base class instead, which supports child component composition.
!!! note
The HtmlTextWriter shim runs synchronously during BuildRenderTree. Complex async operations should be performed in lifecycle methods (OnInitializedAsync, etc.) and stored in fields that RenderContents reads.
!!! warning
Controls that depend on Page.Request or other page-level features should also inherit from WebFormsPageBase or inject the appropriate shims via DI.