Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Migrate custom ASP.NET Web Forms server controls (WebControl, CompositeControl) to Blazor using BlazorWebFormsComponents. Covers RenderContents/HtmlTextWriter preservation, TagKey mapping, AddAttributesToRender, CreateChildControls, and the one-line-change migration pattern. WHEN: 'migrate custom control', 'webcontrol to blazor', 'rendercontents migration', 'htmltextwriter blazor', 'custom server control'. FOR SINGLE OPERATIONS: use /bwfc-ascx-migration for .ascx user controls, /bwfc-migration f
.claude/skills/fritzandfriends-bwfc-custom-control-migration/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-02 | ✗→✓ | ▲ Improved | 58% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 23% | 0% |
| case-07 | ✗→✓ | ▲ Improved | 135% | 0% |
| case-08 | ✗→✓ | ▲ Improved | 62% | 0% |
| case-10 | ✗→✓ | ▲ Improved | 87% | 0% |
ASP.NET Web Forms custom server controls inherit from System.Web.UI.WebControls.WebControl and render HTML imperatively via HtmlTextWriter. BlazorWebFormsComponents provides a drop-in replacement that lets this code work unchanged in Blazor.
The key principle: change one using statement, keep your code.
csharp// Before (Web Forms) using System.Web.UI.WebControls; // After (Blazor + BWFC) using BlazorWebFormsComponents.CustomControls;
The BWFC WebControl class overrides Blazor's BuildRenderTree:
HtmlTextWriter instance (backed by StringBuilder)AddAttributesToRender(writer) — emits ID, class, style, tooltip, disabledRender(writer) → RenderBeginTag → RenderContents → RenderEndTagwriter.GetHtml()builder.AddMarkupContent(0, html)Your imperative rendering code produces the exact same HTML it did in Web Forms.
The classic Web Forms pattern — override TagKey for the outer element, RenderContents for the inner HTML:
csharpusing BlazorWebFormsComponents.CustomControls; using Microsoft.AspNetCore.Components; public class StatusBadge : WebControl { [Parameter] // Only addition for Blazor public string Status { get; set; } protected override HtmlTextWriterTag TagKey => HtmlTextWriterTag.Span; protected override void RenderContents(HtmlTextWriter writer) { writer.RenderBeginTag(HtmlTextWriterTag.Strong); writer.Write(Status); writer.RenderEndTag(); } }
For controls that need complete control over output:
csharppublic 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(); } }
Add data attributes, ARIA attributes, or other custom attributes to the outer tag:
csharppublic class DataPanel : WebControl { [Parameter] public string DataId { get; set; } protected override HtmlTextWriterTag TagKey => HtmlTextWriterTag.Div; protected override void AddAttributesToRender(HtmlTextWriter writer) { base.AddAttributesToRender(writer); // ID, CssClass, Style, ToolTip, Enabled writer.AddAttribute("data-panel-id", DataId); writer.AddAttribute("role", "region"); } protected override void RenderContents(HtmlTextWriter writer) { writer.Write("Panel content here"); } }
For controls that compose multiple child controls:
csharppublic class SearchBox : CompositeControl { [Parameter] public string Placeholder { get; set; } = "Search..."; protected override void Render(HtmlTextWriter writer) { writer.AddAttribute(HtmlTextWriterAttribute.Class, "search-container"); writer.RenderBeginTag(HtmlTextWriterTag.Div); writer.AddAttribute(HtmlTextWriterAttribute.Type, "text"); writer.AddAttribute(HtmlTextWriterAttribute.Placeholder, Placeholder); writer.RenderBeginTag(HtmlTextWriterTag.Input); writer.RenderEndTag(); writer.AddAttribute(HtmlTextWriterAttribute.Type, "submit"); writer.RenderBeginTag(HtmlTextWriterTag.Button); writer.Write("Go"); writer.RenderEndTag(); writer.RenderEndTag(); // div } }
diff- using System.Web.UI; - using System.Web.UI.WebControls; + using BlazorWebFormsComponents.CustomControls; + using Microsoft.AspNetCore.Components;
diff+ [Parameter] public string Title { get; set; } + [Parameter] public bool ShowHeader { get; set; } = true;
Remove or stub these if present (they have no Blazor equivalent):
| Member | Action | |--------|--------| | ViewState property bags | Replace with private fields | | CreateChildControls() | Move logic to Render or RenderContents | | EnsureChildControls() | Remove (no lazy initialization needed) | | INamingContainer | Remove (Blazor handles scoping differently) | | IPostBackDataHandler | Remove (use Blazor events instead) | | IPostBackEventHandler | Remove (use EventCallback) |
The control should render identical HTML. Test with bUnit:
razor@inherits BlazorWebFormsTestContext @code { [Fact] public void MyControl_RendersExpectedHtml() { var cut = Render(@<MyControl Title="Hello" />); cut.Markup.ShouldContain("<div"); cut.Markup.ShouldContain("Hello"); } }
| BWFC Class | Inherits | Use When | |-----------|----------|----------| | WebControl | BaseStyledComponent | Controls with RenderContents/HtmlTextWriter rendering | | CompositeControl | WebControl | Controls that compose multiple child elements | | UserControl | BaseStyledComponent | ASCX code-behind classes (markup-driven) | | Control | BaseWebFormsComponent | Bare controls with no styling |
The BWFC HtmlTextWriter shim supports:
| Method | Description | |--------|-------------| | Write(string) | Write raw text | | WriteLine(string) | Write text + newline | | RenderBeginTag(HtmlTextWriterTag) | Open an HTML element | | RenderBeginTag(string) | Open an HTML element by name | | RenderEndTag() | Close the current element | | AddAttribute(string, string) | Add attribute to next tag | | AddAttribute(HtmlTextWriterAttribute, string) | Add attribute by enum | | AddStyleAttribute(string, string) | Add inline style to next tag | | AddStyleAttribute(HtmlTextWriterStyle, string) | Add style by enum |
All standard HtmlTextWriterTag, HtmlTextWriterAttribute, and HtmlTextWriterStyle enum values are supported.
These work automatically on all WebControl-derived components:
| Property | Renders As | |----------|-----------| | CssClass | class="..." | | ID | id="..." | | ToolTip | title="..." | | Enabled="false" | disabled="disabled" | | Visible="false" | No output | | Style | style="..." |
| Feature | Reason | Alternative | |---------|--------|-------------| | Page.Controls.Add(ctrl) | No dynamic control tree in Blazor | Use RenderFragment or DynamicComponent | | ViewState["key"] | No ViewState persistence | Use private fields or cascading parameters | | PostBack events | No postback in Blazor | Use EventCallback or Blazor events | | Designer support | No designer in Blazor | N/A | | Async in RenderContents | Runs synchronously in BuildRenderTree | Fetch data in lifecycle, render from fields |
The webforms-to-blazor CLI automatically:
.cs files inheriting System.Web.UI.WebControls.WebControlSystem.Web.UI namespace prefix → bare WebControlusing BlazorWebFormsComponents.CustomControls;@inherits WebControl into paired .razor files[Parameter] to public propertiesFor each custom WebControl being migrated:
System.Web.UI.WebControls → BlazorWebFormsComponents.CustomControlsusing Microsoft.AspNetCore.Components; added[Parameter] attributeViewState usage replaced with private fieldsCreateChildControls() logic moved to Render/RenderContentsIPostBackDataHandler/IPostBackEventHandler removed.razor pages: <MyControl Property="value" />Other measured skills in the registry, with their headline benchmark lift.