diff --git a/src/Components/Web/src/Forms/DisplayName.cs b/src/Components/Web/src/Forms/DisplayName.cs
new file mode 100644
index 000000000000..964c71e2a67b
--- /dev/null
+++ b/src/Components/Web/src/Forms/DisplayName.cs
@@ -0,0 +1,109 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections.Concurrent;
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+using System.Linq.Expressions;
+using System.Reflection;
+using Microsoft.AspNetCore.Components.HotReload;
+using Microsoft.AspNetCore.Components.Rendering;
+
+namespace Microsoft.AspNetCore.Components.Forms;
+
+///
+/// Displays the display name for a specified field, reading from
+/// or if present, or falling back to the property name.
+///
+/// The type of the field.
+public class DisplayName : IComponent
+{
+ private static readonly ConcurrentDictionary _displayNameCache = new();
+
+ private RenderHandle _renderHandle;
+ private Expression>? _previousFieldAccessor;
+ private string? _displayName;
+
+ ///
+ /// Specifies the field for which the display name should be shown.
+ ///
+ [Parameter, EditorRequired]
+ public Expression>? For { get; set; }
+
+ static DisplayName()
+ {
+ if (HotReloadManager.Default.MetadataUpdateSupported)
+ {
+ HotReloadManager.Default.OnDeltaApplied += ClearCache;
+ }
+ }
+
+ ///
+ void IComponent.Attach(RenderHandle renderHandle)
+ {
+ _renderHandle = renderHandle;
+ }
+
+ ///
+ Task IComponent.SetParametersAsync(ParameterView parameters)
+ {
+ parameters.SetParameterProperties(this);
+
+ if (For is null)
+ {
+ throw new InvalidOperationException($"{GetType()} requires a value for the " +
+ $"{nameof(For)} parameter.");
+ }
+
+ // Only recalculate if the expression changed
+ if (For != _previousFieldAccessor)
+ {
+ var member = ExpressionMemberAccessor.GetMemberInfo(For);
+ var newDisplayName = GetDisplayName(member);
+
+ if (newDisplayName != _displayName)
+ {
+ _displayName = newDisplayName;
+ _renderHandle.Render(BuildRenderTree);
+ }
+
+ _previousFieldAccessor = For;
+ }
+
+ return Task.CompletedTask;
+ }
+
+ private void BuildRenderTree(RenderTreeBuilder builder)
+ {
+ builder.AddContent(0, _displayName);
+ }
+
+ private static string GetDisplayName(MemberInfo member)
+ {
+ return _displayNameCache.GetOrAdd(member, static m =>
+ {
+ var displayAttribute = m.GetCustomAttribute();
+ if (displayAttribute is not null)
+ {
+ var name = displayAttribute.GetName();
+ if (name is not null)
+ {
+ return name;
+ }
+ }
+
+ var displayNameAttribute = m.GetCustomAttribute();
+ if (displayNameAttribute?.DisplayName is not null)
+ {
+ return displayNameAttribute.DisplayName;
+ }
+
+ return m.Name;
+ });
+ }
+
+ private static void ClearCache()
+ {
+ _displayNameCache.Clear();
+ }
+}
diff --git a/src/Components/Web/src/Forms/ExpressionMemberAccessor.cs b/src/Components/Web/src/Forms/ExpressionMemberAccessor.cs
new file mode 100644
index 000000000000..89ee34d7582d
--- /dev/null
+++ b/src/Components/Web/src/Forms/ExpressionMemberAccessor.cs
@@ -0,0 +1,54 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections.Concurrent;
+using System.Linq.Expressions;
+using System.Reflection;
+using Microsoft.AspNetCore.Components.HotReload;
+
+namespace Microsoft.AspNetCore.Components.Forms;
+
+internal static class ExpressionMemberAccessor
+{
+ private static readonly ConcurrentDictionary _memberInfoCache = new();
+
+ static ExpressionMemberAccessor()
+ {
+ if (HotReloadManager.Default.MetadataUpdateSupported)
+ {
+ HotReloadManager.Default.OnDeltaApplied += ClearCache;
+ }
+ }
+
+ public static MemberInfo GetMemberInfo(Expression> accessor)
+ {
+ ArgumentNullException.ThrowIfNull(accessor);
+
+ return _memberInfoCache.GetOrAdd(accessor, static expr =>
+ {
+ var lambdaExpression = (LambdaExpression)expr;
+ var accessorBody = lambdaExpression.Body;
+
+ if (accessorBody is UnaryExpression unaryExpression
+ && unaryExpression.NodeType == ExpressionType.Convert
+ && unaryExpression.Type == typeof(object))
+ {
+ accessorBody = unaryExpression.Operand;
+ }
+
+ if (accessorBody is not MemberExpression memberExpression)
+ {
+ throw new ArgumentException(
+ $"The provided expression contains a {accessorBody.GetType().Name} which is not supported. " +
+ $"Only simple member accessors (fields, properties) of an object are supported.");
+ }
+
+ return memberExpression.Member;
+ });
+ }
+
+ private static void ClearCache()
+ {
+ _memberInfoCache.Clear();
+ }
+}
diff --git a/src/Components/Web/src/PublicAPI.Unshipped.txt b/src/Components/Web/src/PublicAPI.Unshipped.txt
index 369f33715778..9d7bb1b72c24 100644
--- a/src/Components/Web/src/PublicAPI.Unshipped.txt
+++ b/src/Components/Web/src/PublicAPI.Unshipped.txt
@@ -40,3 +40,7 @@ Microsoft.AspNetCore.Components.Web.Media.MediaSource.MediaSource(byte[]! data,
Microsoft.AspNetCore.Components.Web.Media.MediaSource.MediaSource(System.IO.Stream! stream, string! mimeType, string! cacheKey) -> void
Microsoft.AspNetCore.Components.Web.Media.MediaSource.MimeType.get -> string!
Microsoft.AspNetCore.Components.Web.Media.MediaSource.Stream.get -> System.IO.Stream!
+Microsoft.AspNetCore.Components.Forms.DisplayName
+Microsoft.AspNetCore.Components.Forms.DisplayName.DisplayName() -> void
+Microsoft.AspNetCore.Components.Forms.DisplayName.For.get -> System.Linq.Expressions.Expression!>?
+Microsoft.AspNetCore.Components.Forms.DisplayName.For.set -> void
diff --git a/src/Components/Web/test/Forms/DisplayNameTest.cs b/src/Components/Web/test/Forms/DisplayNameTest.cs
new file mode 100644
index 000000000000..f7835c540827
--- /dev/null
+++ b/src/Components/Web/test/Forms/DisplayNameTest.cs
@@ -0,0 +1,232 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+using Microsoft.AspNetCore.Components.Rendering;
+using Microsoft.AspNetCore.Components.Test.Helpers;
+
+namespace Microsoft.AspNetCore.Components.Forms;
+
+public class DisplayNameTest
+{
+ [Fact]
+ public async Task ThrowsIfNoForParameterProvided()
+ {
+ // Arrange
+ var rootComponent = new TestHostComponent
+ {
+ InnerContent = builder =>
+ {
+ builder.OpenComponent>(0);
+ builder.CloseComponent();
+ }
+ };
+
+ var testRenderer = new TestRenderer();
+ var componentId = testRenderer.AssignRootComponentId(rootComponent);
+
+ // Act & Assert
+ var ex = await Assert.ThrowsAsync(
+ async () => await testRenderer.RenderRootComponentAsync(componentId));
+ Assert.Contains("For", ex.Message);
+ Assert.Contains("parameter", ex.Message);
+ }
+
+ [Fact]
+ public async Task DisplaysPropertyNameWhenNoAttributePresent()
+ {
+ // Arrange
+ var model = new TestModel();
+ var rootComponent = new TestHostComponent
+ {
+ InnerContent = builder =>
+ {
+ builder.OpenComponent>(0);
+ builder.AddComponentParameter(1, "For", (System.Linq.Expressions.Expression>)(() => model.PlainProperty));
+ builder.CloseComponent();
+ }
+ };
+
+ // Act
+ var output = await RenderAndGetOutput(rootComponent);
+
+ // Assert
+ Assert.Equal("PlainProperty", output);
+ }
+
+ [Fact]
+ public async Task DisplaysDisplayAttributeName()
+ {
+ // Arrange
+ var model = new TestModel();
+ var rootComponent = new TestHostComponent
+ {
+ InnerContent = builder =>
+ {
+ builder.OpenComponent>(0);
+ builder.AddComponentParameter(1, "For", (System.Linq.Expressions.Expression>)(() => model.PropertyWithDisplayAttribute));
+ builder.CloseComponent();
+ }
+ };
+
+ // Act
+ var output = await RenderAndGetOutput(rootComponent);
+
+ // Assert
+ Assert.Equal("Custom Display Name", output);
+ }
+
+ [Fact]
+ public async Task DisplaysDisplayNameAttributeName()
+ {
+ // Arrange
+ var model = new TestModel();
+ var rootComponent = new TestHostComponent
+ {
+ InnerContent = builder =>
+ {
+ builder.OpenComponent>(0);
+ builder.AddComponentParameter(1, "For", (System.Linq.Expressions.Expression>)(() => model.PropertyWithDisplayNameAttribute));
+ builder.CloseComponent();
+ }
+ };
+
+ // Act
+ var output = await RenderAndGetOutput(rootComponent);
+
+ // Assert
+ Assert.Equal("Custom DisplayName", output);
+ }
+
+ [Fact]
+ public async Task DisplayAttributeTakesPrecedenceOverDisplayNameAttribute()
+ {
+ // Arrange
+ var model = new TestModel();
+ var rootComponent = new TestHostComponent
+ {
+ InnerContent = builder =>
+ {
+ builder.OpenComponent>(0);
+ builder.AddComponentParameter(1, "For", (System.Linq.Expressions.Expression>)(() => model.PropertyWithBothAttributes));
+ builder.CloseComponent();
+ }
+ };
+
+ // Act
+ var output = await RenderAndGetOutput(rootComponent);
+
+ // Assert
+ // DisplayAttribute should take precedence per MVC conventions
+ Assert.Equal("Display Takes Precedence", output);
+ }
+
+ [Fact]
+ public async Task WorksWithDifferentPropertyTypes()
+ {
+ // Arrange
+ var model = new TestModel();
+ var intComponent = new TestHostComponent
+ {
+ InnerContent = builder =>
+ {
+ builder.OpenComponent>(0);
+ builder.AddComponentParameter(1, "For", (System.Linq.Expressions.Expression>)(() => model.IntProperty));
+ builder.CloseComponent();
+ }
+ };
+ var dateComponent = new TestHostComponent
+ {
+ InnerContent = builder =>
+ {
+ builder.OpenComponent>(0);
+ builder.AddComponentParameter(1, "For", (System.Linq.Expressions.Expression>)(() => model.DateProperty));
+ builder.CloseComponent();
+ }
+ };
+
+ // Act
+ var intOutput = await RenderAndGetOutput(intComponent);
+ var dateOutput = await RenderAndGetOutput(dateComponent);
+
+ // Assert
+ Assert.Equal("Integer Value", intOutput);
+ Assert.Equal("Date Value", dateOutput);
+ }
+
+ [Fact]
+ public async Task SupportsLocalizationWithResourceType()
+ {
+ var model = new TestModel();
+ var rootComponent = new TestHostComponent
+ {
+ InnerContent = builder =>
+ {
+ builder.OpenComponent>(0);
+ builder.AddComponentParameter(1, "For", (System.Linq.Expressions.Expression>)(() => model.PropertyWithResourceBasedDisplay));
+ builder.CloseComponent();
+ }
+ };
+
+ var output = await RenderAndGetOutput(rootComponent);
+ Assert.Equal("Localized Display Name", output);
+ }
+
+ private static async Task RenderAndGetOutput(TestHostComponent rootComponent)
+ {
+ var testRenderer = new TestRenderer();
+ var componentId = testRenderer.AssignRootComponentId(rootComponent);
+ await testRenderer.RenderRootComponentAsync(componentId);
+
+ var batch = testRenderer.Batches.Single();
+ var displayLabelComponentFrame = batch.ReferenceFrames
+ .First(f => f.FrameType == RenderTree.RenderTreeFrameType.Component &&
+ f.Component is DisplayName or DisplayName or DisplayName);
+
+ // Find the text content frame within the component
+ var textFrame = batch.ReferenceFrames
+ .First(f => f.FrameType == RenderTree.RenderTreeFrameType.Text);
+
+ return textFrame.TextContent;
+ }
+
+ private class TestHostComponent : ComponentBase
+ {
+ public RenderFragment InnerContent { get; set; }
+
+ protected override void BuildRenderTree(RenderTreeBuilder builder)
+ {
+ InnerContent(builder);
+ }
+ }
+
+ private class TestModel
+ {
+ public string PlainProperty { get; set; } = string.Empty;
+
+ [Display(Name = "Custom Display Name")]
+ public string PropertyWithDisplayAttribute { get; set; } = string.Empty;
+
+ [DisplayName("Custom DisplayName")]
+ public string PropertyWithDisplayNameAttribute { get; set; } = string.Empty;
+
+ [Display(Name = "Display Takes Precedence")]
+ [DisplayName("This Should Not Be Used")]
+ public string PropertyWithBothAttributes { get; set; } = string.Empty;
+
+ [Display(Name = "Integer Value")]
+ public int IntProperty { get; set; }
+
+ [Display(Name = "Date Value")]
+ public DateTime DateProperty { get; set; }
+
+ [Display(Name = nameof(TestResources.LocalizedDisplayName), ResourceType = typeof(TestResources))]
+ public string PropertyWithResourceBasedDisplay { get; set; } = string.Empty;
+ }
+
+ public static class TestResources
+ {
+ public static string LocalizedDisplayName => "Localized Display Name";
+ }
+}
diff --git a/src/Components/test/E2ETest/Tests/FormsTest.cs b/src/Components/test/E2ETest/Tests/FormsTest.cs
index b58dee62cf0b..304ea1721abc 100644
--- a/src/Components/test/E2ETest/Tests/FormsTest.cs
+++ b/src/Components/test/E2ETest/Tests/FormsTest.cs
@@ -555,6 +555,32 @@ public void ErrorsFromCompareAttribute()
Browser.Empty(confirmEmailValidationMessage);
}
+ [Fact]
+ public void DisplayNameReadsAttributesCorrectly()
+ {
+ var appElement = Browser.MountTestComponent();
+
+ // Check that DisplayAttribute.Name is displayed
+ var displayNameLabel = appElement.FindElement(By.Id("product-name-label"));
+ Browser.Equal("Product Name", () => displayNameLabel.Text);
+
+ // Check that DisplayNameAttribute is displayed
+ var priceLabel = appElement.FindElement(By.Id("price-label"));
+ Browser.Equal("Unit Price", () => priceLabel.Text);
+
+ // Check that DisplayAttribute takes precedence over DisplayNameAttribute
+ var stockLabel = appElement.FindElement(By.Id("stock-label"));
+ Browser.Equal("Stock Quantity", () => stockLabel.Text);
+
+ // Check fallback to property name when no attributes present
+ var descriptionLabel = appElement.FindElement(By.Id("description-label"));
+ Browser.Equal("Description", () => descriptionLabel.Text);
+
+ // Check that ResourceType localization works with English resources
+ var localizedLabel = appElement.FindElement(By.Id("localized-label"));
+ Browser.Equal("Product Name", () => localizedLabel.Text);
+ }
+
[Fact]
public void InputComponentsCauseContainerToRerenderOnChange()
{
diff --git a/src/Components/test/testassets/BasicTestApp/FormsTest/DisplayNameComponent.razor b/src/Components/test/testassets/BasicTestApp/FormsTest/DisplayNameComponent.razor
new file mode 100644
index 000000000000..fda324c7c8a9
--- /dev/null
+++ b/src/Components/test/testassets/BasicTestApp/FormsTest/DisplayNameComponent.razor
@@ -0,0 +1,35 @@
+@using System.ComponentModel
+@using System.ComponentModel.DataAnnotations
+@using Microsoft.AspNetCore.Components.Forms
+
+
+
+
+
+
+
+
+
+@code {
+ private Product _product = new Product();
+
+ class Product
+ {
+ [Display(Name = "Product Name")]
+ public string Name { get; set; } = "Sample";
+
+ [DisplayName("Unit Price")]
+ public decimal Price { get; set; } = 99.99m;
+
+ [Display(Name = "Stock Quantity")]
+ [DisplayName("Stock Amount")] // This should be ignored, Display takes precedence
+ public int StockQuantity { get; set; } = 100;
+
+ // No attributes - should fall back to property name
+ public string Description { get; set; } = "Test";
+
+ // Uses resource file for localization - will show "Product Name" in en-US, "Nom du produit" in fr-FR
+ [Display(Name = nameof(TestResources.ProductName), ResourceType = typeof(TestResources))]
+ public string LocalizedName { get; set; } = "Localized";
+ }
+}
diff --git a/src/Components/test/testassets/BasicTestApp/Index.razor b/src/Components/test/testassets/BasicTestApp/Index.razor
index f9cc718f7ca1..ec6bb63c489d 100644
--- a/src/Components/test/testassets/BasicTestApp/Index.razor
+++ b/src/Components/test/testassets/BasicTestApp/Index.razor
@@ -27,6 +27,7 @@
+
diff --git a/src/Components/test/testassets/BasicTestApp/Resources.fr.resx b/src/Components/test/testassets/BasicTestApp/Resources.fr.resx
index 1371dedf99d7..5cffee6d040d 100644
--- a/src/Components/test/testassets/BasicTestApp/Resources.fr.resx
+++ b/src/Components/test/testassets/BasicTestApp/Resources.fr.resx
@@ -120,4 +120,7 @@
Bonjour!
+
+ Nom du produit
+
\ No newline at end of file
diff --git a/src/Components/test/testassets/BasicTestApp/Resources.resx b/src/Components/test/testassets/BasicTestApp/Resources.resx
index f160f449685f..4233071d8a4b 100644
--- a/src/Components/test/testassets/BasicTestApp/Resources.resx
+++ b/src/Components/test/testassets/BasicTestApp/Resources.resx
@@ -120,4 +120,7 @@
Hello!
+
+ Product Name
+
\ No newline at end of file
diff --git a/src/Components/test/testassets/BasicTestApp/TestResources.cs b/src/Components/test/testassets/BasicTestApp/TestResources.cs
new file mode 100644
index 000000000000..70f1bb537aaf
--- /dev/null
+++ b/src/Components/test/testassets/BasicTestApp/TestResources.cs
@@ -0,0 +1,11 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace BasicTestApp;
+
+// Wrap resources to make them available as public properties for [Display]. That attribute does not support
+// internal properties.
+public static class TestResources
+{
+ public static string ProductName => Resources.ProductName;
+}
diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Pages/ForgotPassword.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Pages/ForgotPassword.razor
index ae82692ab7e9..5176ea466380 100644
--- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Pages/ForgotPassword.razor
+++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Pages/ForgotPassword.razor
@@ -25,7 +25,9 @@