Forms in a few lines of C#. Describe each field once with a typed FormBuilder<T>; FormCraft renders
the controls, runs your validation on the server and announces required fields to screen readers.
- At a glance
- Why FormCraft
- Installation
- Quick start
- Attribute-based forms
- Examples
- Advanced features
- Accessibility
- Documentation
- Tech Stack
- Contributing
- License
This is the whole of a contact form: a required name, a validated email, a topic and a checkbox.
<FormCraftComponent
TModel="Contact"
Model="@_contact"
Configuration="@_config"
OnValidSubmit="@Send" />
@code {
private readonly Contact _contact = new();
private static readonly SelectOption<string>[] Topics =
[new("general", "General question"), new("bug", "Bug report")];
private readonly IFormConfiguration<Contact> _config =
FormBuilder<Contact>.Create()
.AddField(x => x.Name, f => f
.WithLabel("Name")
.Required())
.AddField(x => x.Email, f => f
.WithLabel("Email")
.Required()
.WithEmailValidation())
.AddField(x => x.Topic, f => f
.WithLabel("Topic")
.WithSelectOptions(Topics))
.AddField(x => x.Consent, f => f
.WithLabel("Reply by email"))
.Build();
private void Send(Contact contact) => Console.WriteLine(contact);
}The same form written by hand as a MudBlazor EditForm runs to roughly three times the lines. The
live demo plays this one out, and shows both listings side by side.
- Typed end to end. Fields are bound with expressions (
x => x.Email), so a renamed property is a compile error, not a blank field. - Validation where it belongs. Built-in rules, custom and async validators, and FluentValidation all run
against your model. Forms render
novalidate, so the browser never shows its own bubbles. - Accessible by default. Required fields carry
aria-requiredon every field type, and controls that remove themselves move keyboard focus somewhere sensible. See Accessibility. - Fields that react.
DependsOn()recalculates, resets or reloads a field when another one changes. - More than text boxes. Groups and columns, steppers and tabs, file uploads, lookups and LOV fields, master-detail collections, custom renderers, and field encryption with CSRF and rate limiting.
- Your UI library. Adapters for MudBlazor and Fluent UI Blazor render the same configuration.
Around 1,600 unit tests cover the core and both adapters. For a feature-by-feature comparison with
EditForm, Blazored.FluentValidation and MudBlazor's own forms, see COMPARISON.md.
Install one UI adapter; each one brings the core package with it.
dotnet add package FormCraft.ForMudBlazor| Package | What it is |
|---|---|
FormCraft |
The UI-agnostic core: builders, configuration, validation, security |
FormCraft.ForMudBlazor |
Renders forms with MudBlazor (≥ 9.9.0) |
FormCraft.ForFluentUI |
Renders forms with Fluent UI Blazor v5. New, and not yet published to NuGet |
Adapters are mutually exclusive: AddFormCraftMudBlazor() and AddFormCraftFluentUI() cannot be
registered in the same container, and the second call throws.
Target frameworks: net8.0 and net10.0.
// Program.cs
builder.Services.AddMudServices(); // MudBlazor services
builder.Services.AddFormCraft(); // FormCraft core services
builder.Services.AddFormCraftMudBlazor(); // MudBlazor renderers for FormCraftpublic class UserRegistration
{
public string FirstName { get; set; }
public string LastName { get; set; }
public string Email { get; set; }
public int Age { get; set; }
public string Country { get; set; }
public bool AcceptTerms { get; set; }
}@page "/register"
@using FormCraft
@using FormCraft.ForMudBlazor
<h3>User Registration</h3>
<FormCraftComponent
TModel="UserRegistration"
Model="@model"
Configuration="@formConfig"
OnValidSubmit="@HandleSubmit"
ShowSubmitButton="true" />
@code {
private UserRegistration model = new();
private IFormConfiguration<UserRegistration> formConfig;
protected override void OnInitialized()
{
formConfig = FormBuilder<UserRegistration>.Create()
.AddRequiredTextField(x => x.FirstName, "First Name")
.AddRequiredTextField(x => x.LastName, "Last Name")
.AddEmailField(x => x.Email)
.AddNumericField(x => x.Age, "Age", min: 18, max: 120)
.AddDropdownField(x => x.Country, "Country",
("us", "United States"),
("uk", "United Kingdom"),
("ca", "Canada"),
("au", "Australia"))
.AddField(x => x.AcceptTerms, field => field
.WithLabel("I accept the terms and conditions")
.Required("You must accept the terms"))
.Build();
}
private async Task HandleSubmit(UserRegistration model)
{
// Handle form submission
await UserService.RegisterAsync(model);
}
}Put the form on the model itself, then generate it in one call.
public class UserRegistration
{
[TextField("First Name", "Enter your first name")]
[Required(ErrorMessage = "First name is required")]
[MinLength(2)]
public string FirstName { get; set; } = string.Empty;
[TextField("Last Name", "Enter your last name")]
[Required(ErrorMessage = "Last name is required")]
public string LastName { get; set; } = string.Empty;
[EmailField("Email Address")]
[Required]
public string Email { get; set; } = string.Empty;
[NumberField("Age", "Your age")]
[Range(18, 120, ErrorMessage = "Age must be between 18 and 120")]
public int Age { get; set; }
[DateField("Date of Birth")]
public DateTime BirthDate { get; set; }
[SelectField("Country", "United States", "Canada", "United Kingdom", "Australia")]
public string Country { get; set; } = string.Empty;
[TextArea("Bio", "Tell us about yourself")]
[MaxLength(500)]
public string Bio { get; set; } = string.Empty;
[CheckboxField("Newsletter", "Subscribe to our newsletter")]
public bool SubscribeToNewsletter { get; set; }
}var formConfig = FormBuilder<UserRegistration>.Create()
.AddFieldsFromAttributes()
.Build();The attributes are [TextField], [EmailField], [NumberField], [DateField], [SelectField],
[CheckboxField] and [TextArea]. They combine with the standard DataAnnotations validators
([Required], [MinLength], [MaxLength], [Range] and the rest).
With no attributes at all, AddFieldsAuto() builds a form from any POCO by reflection, with humanized
labels and a sensible field type per property type.
The same two fields, both ways:
| Fluent API | Attribute-based |
|---|---|
var config = FormBuilder<User>.Create()
.AddField(x => x.Name, field => field
.WithLabel("Full Name")
.WithPlaceholder("Enter name")
.Required("Name is required")
.WithMinLength(2))
.AddField(x => x.Email, field => field
.WithLabel("Email")
.WithInputType("email")
.Required())
.Build(); |
public class User
{
[TextField("Full Name", "Enter name")]
[Required(ErrorMessage = "Name is required")]
[MinLength(2)]
public string Name { get; set; }
[EmailField("Email")]
[Required]
public string Email { get; set; }
}
var config = FormBuilder<User>.Create()
.AddFieldsFromAttributes()
.Build(); |
Every example below has a working counterpart in the live demo, with its source beside the form.
DependsOn(watchedField, callback) runs the callback whenever the watched field changes, so you can reset
or recalculate dependent values:
var formConfig = FormBuilder<OrderForm>.Create()
.AddDropdownField(x => x.ProductType, "Product Type",
("standard", "Standard"),
("premium", "Premium"))
.AddField(x => x.ProductModel, field => field
.WithLabel("Model")
.WithOptions(
("basic", "Basic Model"),
("pro", "Pro Model"))
// Reset the model whenever Product Type changes
.DependsOn(x => x.ProductType, (model, productType) =>
model.ProductModel = string.Empty))
.AddNumericField(x => x.Quantity, "Quantity", min: 1)
.AddField(x => x.TotalPrice, field => field
.WithLabel("Total Price")
.ReadOnly()
// Recalculate the total whenever Quantity changes
.DependsOn(x => x.Quantity, (model, quantity) =>
model.TotalPrice = quantity * GetUnitPrice(model.ProductModel)))
.Build();The callback can also be async: DependsOn(x => x.Country, async (model, country) => ...) re-renders the
form once the work settles, which is how cascading country → state → city lookups are built.
.AddField(x => x.Username, field => field
.WithValidator(
username => !forbiddenUsernames.Contains(username.ToLower()),
"This username is not available")
.WithAsyncValidator(
async username => await UserService.IsUsernameAvailableAsync(username),
"Username is already taken"))When a validator needs other model values or DI services, implement IFieldValidator<TModel, TValue>. Its
ValidateAsync(model, value, services) receives the full model and the IServiceProvider:
public class UniqueUsernameValidator : IFieldValidator<User, string>
{
public string? ErrorMessage { get; set; } = "Username is already taken";
public async Task<ValidationResult> ValidateAsync(
User model, string value, IServiceProvider services)
{
var userService = services.GetRequiredService<IUserService>();
return await userService.IsUsernameAvailableAsync(value)
? ValidationResult.Success()
: ValidationResult.Failure("Username is already taken");
}
}
// Usage
.AddField(x => x.Username, field => field
.WithValidator(new UniqueUsernameValidator()))FluentValidation plugs in too: register your IValidator<TModel> in DI and call
.WithFluentValidation(x => x.Email) on the field.
The default validation messages are localisable. English and French ship, and adding
FormCraft/Resources/ValidationMessages.<culture>.resx adds a language.
// Vertical (default): one field per row
.WithLayout(FormLayout.Vertical)
// Grid: responsive columns, as many 240px-wide columns as fit
.WithLayout(FormLayout.Grid)
// Inline: fields flow side by side and wrap
.WithLayout(FormLayout.Inline)The layout reaches the page as a formcraft-layout-{vertical|grid|inline} class on the form's
fields. Grid and Inline are styled by a stylesheet each adapter ships; link the one for your
adapter from your host page:
<link href="_content/FormCraft.ForMudBlazor/css/formcraft-layout.css" rel="stylesheet" />
<!-- or -->
<link href="_content/FormCraft.ForFluentUI/css/formcraft-layout.css" rel="stylesheet" />FormLayout.Horizontal is [Obsolete]: it was never implemented (every field component draws its
own label, leaving no seam to put it beside the input) and renders like Vertical. Use Grid.
Column counts are configured per field group rather than at the form level:
.AddFieldGroup(group => group
.WithGroupName("Address")
.WithColumns(2) // Two-column layout for this group
.AddField(x => x.City)
.AddField(x => x.PostalCode))// Password field with strength requirements
.AddPasswordField(x => x.Password, "Password", minLength: 8, requireSpecialChars: true)
// Password confirmation via a model-aware validator
.AddField(x => x.ConfirmPassword, field => field
.WithLabel("Confirm Password")
.WithInputType("password")
.Required("Please confirm your password")
.WithValidator(new PasswordsMatchValidator()))
// Date picker with validation (DateTime properties render as date pickers automatically)
.AddField(x => x.BirthDate, field => field
.WithLabel("Date of Birth")
.WithValidator(date => date <= DateTime.Today.AddYears(-18), "Must be 18 or older")
.WithHelpText("Must be 18 or older"))
// Multi-line text with character limit
.AddField(x => x.Description, field => field
.WithLabel("Description")
.AsTextArea(lines: 5, maxLength: 500)
.WithMaxLength(500, "Maximum 500 characters")
.WithHelpText("Maximum 500 characters"))
// File upload
.AddFileUploadField(x => x.Resume, "Upload Resume",
acceptedFileTypes: new[] { ".pdf", ".doc", ".docx" },
maxFileSize: 5 * 1024 * 1024) // 5MB
// Multiple file upload
.AddMultipleFileUploadField(x => x.Documents, "Upload Documents",
maxFiles: 3,
acceptedFileTypes: new[] { ".pdf", ".jpg", ".png" },
maxFileSize: 10 * 1024 * 1024) // 10MB per fileThe password confirmation validator compares against the rest of the model:
public class PasswordsMatchValidator : IFieldValidator<RegistrationModel, string>
{
public string? ErrorMessage { get; set; } = "Passwords do not match";
public Task<ValidationResult> ValidateAsync(
RegistrationModel model, string value, IServiceProvider services)
=> Task.FromResult(value == model.Password
? ValidationResult.Success()
: ValidationResult.Failure("Passwords do not match"));
}DateOnly, TimeOnly and nullable value types (int?, decimal?, DateTime? …) are supported too:
a cleared field writes null back instead of 0 or MinValue.
.AddField(x => x.CompanyName, field => field
.WithLabel("Company Name")
.VisibleWhen(model => model.UserType == UserType.Business))
.AddField(x => x.TaxId, field => field
.WithLabel("Tax ID")
.VisibleWhen(model => model.Country == "US")
.DisabledWhen(model => model.IsLocked))For conditional requiredness, use a model-aware validator that only fails when the condition applies:
.AddField(x => x.TaxId, field => field
.WithLabel("Tax ID")
.WithValidator(new RequiredWhenUsValidator()))
public class RequiredWhenUsValidator : IFieldValidator<BusinessModel, string>
{
public string? ErrorMessage { get; set; } = "Tax ID is required for US companies";
public Task<ValidationResult> ValidateAsync(
BusinessModel model, string value, IServiceProvider services)
=> Task.FromResult(model.Country == "US" && string.IsNullOrWhiteSpace(value)
? ValidationResult.Failure("Tax ID is required for US companies")
: ValidationResult.Success());
}var formConfig = FormBuilder<UserModel>
.Create()
.AddFieldGroup(group => group
.WithGroupName("Personal Information")
.WithColumns(2) // Two-column layout
.ShowInCard(2) // Show in card with elevation 2
.AddField(x => x.FirstName, field => field
.WithLabel("First Name")
.Required())
.AddField(x => x.LastName, field => field
.WithLabel("Last Name")
.Required())
.AddField(x => x.DateOfBirth))
.AddFieldGroup(group => group
.WithGroupName("Contact Information")
.WithColumns(3) // Three-column layout
.ShowInCard() // Default elevation 1
.AddField(x => x.Email)
.AddField(x => x.Phone)
.AddField(x => x.Address))
.Build();Variant and ShrinkLabel are configurable per field, with a form-level default. The field-level value
wins; unconfigured fields render Variant.Outlined with ShrinkLabel="true".
<FormCraftComponent
TModel="UserModel"
Model="@model"
Configuration="@formConfig"
DefaultVariant="Variant.Text"
DefaultShrinkLabel="false" />// Per-field override: a Filled search box among otherwise Text inputs
var formConfig = FormBuilder<UserModel>
.Create()
.AddField(x => x.Query, field => field
.WithLabel("Search")
.WithVariant(Variant.Filled)
.WithShrinkLabel(true))
.Build();Important
ShrinkLabel="false" only shows on an empty field with no placeholder and no start adornment:
MudBlazor pins the label whenever a field has a value, a placeholder or a start adornment. FormCraft
logs one warning (FormCraft.ForMudBlazor.ShrinkLabel) naming every field where the setting cannot
take effect. Rendering is unaffected.
var formConfig = FormBuilder<SecureForm>.Create()
.AddField(x => x.SSN, field => field
.WithLabel("Social Security Number")
.WithPlaceholder("XXX-XX-XXXX"))
.AddField(x => x.CreditCard, field => field
.WithLabel("Credit Card")
.WithPlaceholder("XXXX XXXX XXXX XXXX"))
.WithSecurity(security => security
.EncryptField(x => x.SSN) // Mark sensitive fields for encryption
.EncryptField(x => x.CreditCard)
.EnableCsrfProtection() // Configure anti-forgery tokens
.WithRateLimit(5, TimeSpan.FromMinutes(1)) // Max 5 submissions per minute
.EnableAuditLogging()) // Configure audit logging
.Build();FormCraftComponent enforces these settings itself. It issues a CSRF token and validates it before
OnValidSubmit fires. It checks the rate limit before validation, keyed by the SecurityContextId
parameter (set it to a user, session or IP). It writes FormSubmitted / FormRejected audit entries with
encrypted and excluded fields redacted. A blocked submission shows an alert and never reaches your handler.
Both UI adapters run this through one shared FormSecurityEnforcer<TModel> in core, so they enforce
identically; only the alert component differs.
Encryption stays an application concern. Call encryptionService.EncryptConfiguredFields(model, config.Security)
before persisting. The default IEncryptionService is AES-256 with a random IV per operation; configure a
32-byte key for values that must survive a restart. On WebAssembly a browser-compatible fallback is registered
instead, and it is obfuscation, not encryption. The
security guide has the details.
// Create a custom renderer
public class ColorPickerRenderer : CustomFieldRendererBase<string>
{
public override RenderFragment Render(IFieldRenderContext context)
{
return builder =>
{
var value = GetValue(context) ?? "#000000";
builder.OpenElement(0, "input");
builder.AddAttribute(1, "type", "color");
builder.AddAttribute(2, "value", value);
builder.AddAttribute(3, "onchange", EventCallback.Factory.CreateBinder<string>(
this, async (newValue) => await SetValue(context, newValue), value));
builder.CloseElement();
};
}
}
// Use in your form configuration (TRenderer is checked against the field's value type)
.AddField(x => x.Color, field => field
.WithLabel("Product Color")
.WithCustomRenderer<ColorPickerRenderer>()
.WithHelpText("Select the primary color"))
// Register it in DI when it takes constructor dependencies
services.AddScoped<ColorPickerRenderer>();A component that caches anything derived from its field's configuration (a bound attribute, a resolved display value, a selection list) must reload it in
OnFieldConfigurationChanged(), reassigning every such value on every call — including back to its default — rather than reading it once inOnInitialized. Getting this wrong is silent: the component keeps rendering the previous field's settings after a swap, with no exception and no log line.FieldConfigurationParityTestsin each adapter's test project enforces this mechanically: every field-type component either has a row proving it honours the hook, or is named as a reviewed exemption for genuinely caching nothing — a component with neither fails the build (#349).
- Required fields are announced.
.Required()addsaria-required="true"on every field type that can carry it (text, numeric, date, select, multi-select, autocomplete, lookup, LOV, boolean). It does not add the HTML5requiredattribute, because validation runs on the server. Use.WithNativeRequired()when you want MudBlazor's native asterisk and attribute. - Uploads say so too. A required file upload is marked by a visible asterisk and by a description on its Browse button, the control a keyboard user actually reaches.
- Focus goes somewhere sensible. Controls that remove or disable themselves (clearing an upload, deleting or moving a collection row, adding one) move focus deliberately instead of dropping it to the top of the page.
The MudBlazor adapter needs MudBlazor 9.9.0 or later for the aria-required behaviour.
What changed in each release is in the changelog and on the releases page.
- .NET 10 · .NET 8
- Microsoft.AspNetCore.Components.WebAssembly
- Microsoft.AspNetCore.Components.WebAssembly.DevServer
- MudBlazor
- Microsoft.FluentUI.AspNetCore.Components
- FluentValidation
- Markdig
- bunit
- FakeItEasy
- Shouldly
Contributions are welcome. CONTRIBUTING.md has the details.
git clone https://github.com/phmatray/FormCraft.git
cd FormCraft
dotnet build -c Release
dotnet test -c Release
./pack-local.sh # local NuGet packages in ./nupkg (pack-local.ps1 on Windows)Pull requests target dev and are squash-merged, so the PR title becomes the commit and must be a
Conventional Commit: <type>(<scope>): <subject> (#<issue>).
A CI check enforces it, because that title drives the next version and its changelog entry.
Nothing is versioned or tagged by hand. release-please keeps
a release PR open against dev with the next version and changelog. Merging it tags vX.Y.Z, creates the
GitHub Release and publishes the packages to NuGet.org through Trusted Publishing, all in the same workflow
run. CHANGELOG.md is generated, so never edit it by hand. MinVer derives the package version from the tag.
FormCraft is licensed under the MIT License: free to use, modify and ship, including commercially. Issues and ideas go to GitHub Issues.