microsoft/creating-winforms-custom-controls
> Creates custom controls and UserControls for modern WinForms (.NET 6+). Use when deriving from Control, UserControl, Button, TextBox, or other base controls, implementing custom rendering with OnPaint overrides, creating composite controls with child control composition, adding custom properties with [Browsable], [Category], or [DefaultValue] attributes for Designer support, implementing INotifyPropertyChanged for control properties, handling Layout/LayoutEngine for custom sizing, creating scrollable controls with AutoScrollMinSize, implementing dark mode theming, or building List/Grid/Tree-like controls. Also triggers for "custom UserControl", "derive from Control", "owner-drawn control", "Designer properties", "[Browsable] attribute", "custom WinForms control", "LayoutEngine", "AutoScroll control", and "themed control".
npx skills add https://github.com/microsoft/upgrade-agent-plugins --skill creating-winforms-custom-controls
Authoring custom controls and UserControls for modern WinForms (.NET 6+, C# 13/14): base-class selection, clip-proof layout/sizing, owner-draw, dark-mode theming, Designer serialization, and List/Grid controls.
Use this skill when asked to create, derive, or modernize a WinForms control, UserControl, owner-drawn control, themed/dark-mode-aware control, scrollable Control-derived control, or a List/Grid-like control, and when wiring up Designer serialization for new control properties.
Use it for designing UserControl to offload complex UI from forms, or when a reusable control is needed across multiple forms/projects. Also use it for modernizing legacy controls to be dark-mode aware and DPI-aware.
Tenets: Assume the project's house style:
using statements — globally-imported namespaces only.#nullable enable / nullable mode.is/and/or, switch expressions, collection initializers ([]).var discipline — use var only when the right-hand side is a constructor call or cast that makes the type obvious (e.g., var cp = new CreateParams()). Use explicit types for int, bool, string, float, double, Color, Size, Point, and similar short type names.return — improves readability in multi-statement methods.Decide *generic reusable control* vs *composite/LOB control* first.
Control, or the closest specialized base (ButtonBase, ScrollableControl, ListControl, Panel, Label, —¦). Control gives you a blank canvas with full paint/input control. TextBoxBase is not available — it is internal; if you need text-box-like behavior, derive from TextBox or compose one.UserControl and assemble constituent controls (see §5).DataGridView, not Control from scratch (see §6).Prefer the most specific base that already solves the hard parts — don't reimplement ButtonBase's focus/click mechanics on a raw Control.
A control that looks fine at 100% DPI with the default font but clips at 200% / large fonts is broken. Treat sizing as first-class wherever content needs a *minimum real estate* to render fonts/glyphs/images without clipping.
Implement these as a coherent set:
GetPreferredSize(Size proposedSize) — return the size genuinely needed for current content, font, padding, and DPI. Measure text with TextRenderer.MeasureText (it matches TextRenderer.DrawText). Always add Padding.Size. This is the single source of truth for "how big do I need to be."AutoSize — expose and honor it. When true, the layout engine calls GetPreferredSize; your job is to return an honest number. Re-raise layout when size-affecting content changes (PerformLayout() / Invalidate()).SetBounds(...) — override when you must clamp or adjust the bounds you're given (e.g. enforce a minimum). Respect the BoundsSpecified flags so you don't clobber a dimension the caller didn't set.Padding — honor it in painting *and* GetPreferredSize. Content draws inside the padded rectangle.Rule of thumb: an honest GetPreferredSize plus honored AutoSize/Padding survives DPI and font scaling automatically.
A typical GetPreferredSize for a text-bearing Control-derived control:
public override Size GetPreferredSize(Size proposedSize)
{
Size textSize = TextRenderer.MeasureText(Text, Font);
Size content = new(
textSize.Width + Padding.Horizontal,
textSize.Height + Padding.Vertical);
return new Size(
Math.Max(content.Width, MinimumSize.Width),
Math.Max(content.Height, MinimumSize.Height));
}
OnTextChanged / OnFontChanged overrides should call PerformLayout() (and Invalidate() if owner-drawn) so a new preferred size is picked up while AutoSize is on.
When you take over painting (OnPaint, owner-draw cells, custom rendering), enable double buffering to eliminate flicker:
SetStyle(
ControlStyles.UserPaint
| ControlStyles.AllPaintingInWmPaint
| ControlStyles.OptimizedDoubleBuffer,
true);
Do all painting in OnPaint — don't scatter logic into OnPaintBackground. Call Invalidate() (not Refresh()) to request a repaint so it coalesces with other invalidations rather than forcing a synchronous redraw.
Modern WinForms supports dark mode. Two things bite custom controls:
Control-derived class are not themed automaticallyIf you add scrollbars by calling Win32 (WS_HSCROLL / WS_VSCROLL, ShowScrollBar, etc.) on a Control subclass, those scrollbars render in the classic (light) theme even in dark mode. To get them themed, override CreateParams and opt in:
protected override CreateParams CreateParams
{
get
{
SetStyle(ControlStyles.ApplyThemingImplicitly, true); // opt-in; false = opt-out
CreateParams cp = base.CreateParams;
// add WS_HSCROLL / WS_VSCROLL here as needed
return cp;
}
}
This cannot be done in the constructor: the base constructor reads CreateParams *before* your constructor body runs. It must live in the CreateParams getter, with the SetStyle call placed *before* base.CreateParams is read.
SystemColors flip in dark mode — names become counter-intuitiveIn dark mode, SystemColors are remapped to roughly complementary values. The name no longer matches the apparent brightness, and that is intentional: ControlLightLight is very bright in classic mode and correspondingly dark in dark mode. The remap is *not* always a strict mathematical complement — some colors are nudged to keep contrast adequate.
Consequences:
SystemColors, accept that the name describes its *classic-mode* role, not its color.SystemColors for your control's palette. Define your own colors as explicit #AARRGGBB values with separate defaults for classic and dark mode.Application.IsDarkModeEnabled, which reflects the *current* mode:Color faceColor = Application.IsDarkModeEnabled
? Color.FromArgb(unchecked((int)0xFF2D2D30))
: Color.FromArgb(unchecked((int)0xFFF0F0F0));
For a UserControl (or any control composed of constituent controls):
Font definitions. Don't set Font on the UserControl or its children at design time. Design-time fonts pollute the HighDPI AutoScaleDimensions code generation, so when the user later re-opens the control in the Designer the generated scaling code is wrong. Adjust font *size* only at runtime.TableLayoutPanel with AutoSize rows/columns. Let the table compute geometry.AutoSize and an honest GetPreferredSize big enough to render every child unclipped across all HighDPI and large-font scenarios (§2). The TableLayoutPanel with AutoSize cells does most of this for you, but verify the outer control reports it.Panel (AutoScroll = true) rather than forcing an unreasonable minimum size.When asked for anything list- or grid-shaped, derive from DataGridView rather than building from scratch.
For image-rich or graphically rich data records, the best outcome is usually to render the entire data item in one cell via a custom DataGridViewCell — symbol fonts, multiple colors, multiple font sizes, multi-line layout, all in a single cell. Skip column headers in that case.
Keep column headers (and therefore per-field columns) only when the context genuinely needs a schema overview or per-column sort/filter. Even then, prefer custom cell rendering to get the rich graphical UI; don't fall back to plain text just because headers exist.
Whenever cells vary in height, compute row height from the tallest cell in the data row — measure each cell's required height and set the row to the max, or the content clips.
Every public property you add must tell the Designer how to serialize it. Pick exactly one of these three mechanisms (they conflict if combined):
[DesignerSerializationVisibility(...)] — Hidden for runtime-only/derived properties the Designer must not write to InitializeComponent; Content for collections whose items serialize individually.[DefaultValue(...)] — the property serializes only when its value differs from this constant. Use for simple properties with a fixed, compile-time-constant default.ResetXxx() + ShouldSerializeXxx() pair — ShouldSerializeXxx returns whether the value should be written; ResetXxx restores the default. Use this for ambient properties, and whenever a default is *not constant* (depends on another property or the theme) — [DefaultValue] can't express that.The method pair, for a property whose default depends on the current theme:
/// <summary>Gets or sets the color of the control's face.</summary>
public Color FaceColor { get; set; } = DefaultFaceColor;
private static Color DefaultFaceColor
=> Application.IsDarkModeEnabled
? Color.FromArgb(unchecked((int)0xFF2D2D30))
: Color.FromArgb(unchecked((int)0xFFF0F0F0));
// The Designer discovers these by naming convention — keep them private.
private bool ShouldSerializeFaceColor()
=> FaceColor != DefaultFaceColor;
private void ResetFaceColor()
=> FaceColor = DefaultFaceColor;
Note the convention: the methods are private, named exactly ShouldSerialize<Property> / Reset<Property>, and take no parameters. Don't combine them with [DefaultValue] on the same property — the two mechanisms conflict.
Don't leave a property with none of these — the Designer will either over-serialize or fail to round-trip.
Beyond serialization, decorate every new public property so it behaves well in the Properties window and IntelliSense. These are four *distinct* concerns:
[Category("Appearance")] — the Properties-window group. Reuse standard names (Appearance, Behavior, Layout, Data, —¦) so your properties merge with the built-in ones.[Description("...")] — help text in the Properties window's description pane. Write it for a control *consumer*.[Browsable(false)] — hides the property from the Properties window. Use for runtime-only state. It almost always also needs [DesignerSerializationVisibility(Hidden)] — hiding from the grid does *not* stop serialization.[EditorBrowsable(EditorBrowsableState.Never | Advanced)] — controls IntelliSense visibility, independent of the grid. Never hides from completion; Advanced shows only when "Hide advanced members" is off.To fully suppress an inherited property that's meaningless on your control (e.g. shadowing Text), combine all three on the new/override member:
[Browsable(false)]
[EditorBrowsable(EditorBrowsableState.Never)]
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
public override string Text
{
get => base.Text;
set => base.Text = value;
}
A normal, visible property reads like this:
[Category("Appearance")]
[Description("The color used to paint the control's face.")]
public Color FaceColor { get; set; } = DefaultFaceColor;
ISupportInitializeDuring InitializeComponent, the Designer sets your properties one at a time in an order *you don't control*. If two properties are interdependent — or a setter does expensive work (re-layout, allocation, a Win32 round-trip) — the control repeats that work against a half-configured state, and may even throw because property B isn't set yet when property A's setter runs.
Implement ISupportInitialize to defer it. The Designer emits BeginInit() before the property block and EndInit() after; do nothing expensive in between:
public class GaugeControl : Control, ISupportInitialize
{
private bool _initializing;
public void BeginInit()
=> _initializing = true;
public void EndInit()
{
_initializing = false;
RecalculateLayout(); // the deferred work, done once, fully configured
Invalidate();
}
public int Minimum
{
get;
set
{
field = value;
if (!_initializing)
{
RecalculateLayout();
Invalidate();
}
}
}
}
Every interdependent setter checks _initializing and skips the expensive path while it's true. EndInit runs that path exactly once. At runtime (no Designer), code that sets properties directly without calling BeginInit/EndInit still works — _initializing is just false, so each setter does its work immediately.
Designer functionality in .NET 6+ requires the WinForms Designer SDK NuGet package.
Microsoft.WinForms.Designer.SDK 1.13.0-preview.2.24575.3. The latest stable (1.6.0) is missing features you'll likely need — prefer the preview.CodeDomSerializers, custom TypeConverters, and custom DesignerActionList (smart-tag) actions.Control generic, UserControl composite, DataGridView list/grid).GetPreferredSize honest; AutoSize + Padding honored; survives 200% DPI / large fonts.OptimizedDoubleBuffer | AllPaintingInWmPaint | UserPaint; repaint via Invalidate().#AARRGGBB palette per mode, switched on Application.IsDarkModeEnabled; Win32 scrollbars themed via CreateParams + ApplyThemingImplicitly.TableLayoutPanel with AutoSize cells, scrolling Panel if too large.[DefaultValue], [DesignerSerializationVisibility], or ShouldSerializeXxx/ResetXxx; plus [Category]/[Description].ISupportInitialize with a _initializing guard.1.13.0-preview.2.24575.3 if design-time code is involved.Take microsoft/creating-winforms-custom-controls from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.