Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -424,3 +424,4 @@ all-icons.json
all-emojis.json
/global.json
/src/Core.Scripts/src/BuildConstants.ts
/src/Core.Scripts/obj/
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@
StripedRows="true"
ResizableColumns="true"
DisplayMode="DataGridDisplayMode.Grid">
<PropertyColumn Title="ID" Property="@(e => e.Id)" Width="100px" Pin="DataGridColumnPin.Left" Sortable="true" />
<PropertyColumn Title="Full Name" Property="@(e => e.FullName)" Width="160px" Pin="DataGridColumnPin.Left" Sortable="true" />
<PropertyColumn Title="ID" Property="@(e => e.Id)" Width="100px" Pin="DataGridColumnPin.Start" Sortable="true" />
<PropertyColumn Title="Full Name" Property="@(e => e.FullName)" Width="160px" Pin="DataGridColumnPin.Start" Sortable="true" />
<PropertyColumn Title="Department" Property="@(e => e.Department)" Sortable="true" />
<PropertyColumn Title="Location" Property="@(e => e.Location)" Sortable="true" />
<PropertyColumn Title="Start Date" Property="@(e => e.StartDate)" Sortable="true" />
<PropertyColumn Title="Salary" Property="@(e => e.Salary)" Width="120px" Sortable="true" Align="DataGridCellAlignment.End" />
<TemplateColumn Title="Actions" Width="120px" Pin="DataGridColumnPin.Right">
<TemplateColumn Title="Actions" Width="120px" Pin="DataGridColumnPin.End">
<FluentButton IconStart="@(new Icons.Regular.Size16.Edit())" Appearance="ButtonAppearance.Subtle" Title="Edit" @onclick="@(() => selectedName = context.FullName + " (edit)")" />
<FluentButton IconStart="@(new Icons.Regular.Size16.Delete())" Appearance="ButtonAppearance.Subtle" Title="Delete" @onclick="@(() => selectedName = context.FullName + " (delete)")" />
</TemplateColumn>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
---
title: Pinned columns
order: 0095
route: /DataGrid/PinnedColumns
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The page front matter removed order: 0095, but other DataGrid documentation pages still define order: for nav/menu sorting. If the demo site relies on this for stable ordering, restore the order value (or update the doc-nav logic accordingly).

Suggested change
route: /DataGrid/PinnedColumns
route: /DataGrid/PinnedColumns
order: 0095

Copilot uses AI. Check for mistakes.
---

# Pinned columns

Columns can be pinned (frozen) to the left or right edge of the grid so that they remain visible
while the user scrolls horizontally through wider datasets.
Columns can be pinned (frozen) to the start or end edge of the grid so that they remain visible
while the user scrolls horizontally through wider datasets. Using `Start`/`End` instead of
`Left`/`Right` means pinned columns automatically work correctly in both LTR and RTL layouts.

## Parameters

Expand All @@ -16,19 +16,19 @@ Set the `Pin` parameter on any `PropertyColumn` or `TemplateColumn`:
| Value | Behavior |
|---|---|
| `DataGridColumnPin.None` | Default — column scrolls normally |
| `DataGridColumnPin.Left` | Column stays anchored to the left edge |
| `DataGridColumnPin.Right` | Column stays anchored to the right edge |
| `DataGridColumnPin.Start` | Column stays anchored to the start edge |
| `DataGridColumnPin.End` | Column stays anchored to the end edge |

## Rules

* **Explicit pixel width required.** Every pinned column must declare a `Width` in pixels
(e.g. `Width="150px"`). Relative units (`fr`, `%`) are not supported because the browser cannot
determine a fixed sticky offset from them at render time.
* **Left-pinned columns must be contiguous at the start.** Each left-pinned column must
immediately follow another left-pinned column, or be the very first column.
* **Right-pinned columns must be contiguous at the end.** Each right-pinned column must
immediately precede another right-pinned column, or be the very last column.
* Violating any of these rules throws an `ArgumentException` with a descriptive message.
* **Explicit width required.** Every pinned column must declare a `Width`.
Pixel and non-pixel CSS units are supported. After the grid renders, sticky offsets are
recomputed from the rendered header widths so pinned columns stay aligned.
* **Start-pinned columns must be contiguous at the start.** Each start-pinned column must
immediately follow another start-pinned column, or be the very first column.
* **End-pinned columns must be contiguous at the end.** Each end-pinned column must
immediately precede another end-pinned column, or be the very last column.
* Violating the missing-width or ordering rules throws an `ArgumentException` with a descriptive message.

## Scrollable container

Expand All @@ -39,10 +39,10 @@ bar appears when columns overflow the container:
```razor
<div style="overflow-x: auto;">
<FluentDataGrid Items="@employees" Style="min-width: max-content;">
<PropertyColumn Title="ID" Property="@(e => e.Id)" Width="60px" Pin="DataGridColumnPin.Left" />
<PropertyColumn Title="Name" Property="@(e => e.Name)" Width="160px" Pin="DataGridColumnPin.Left" />
<PropertyColumn Title="ID" Property="@(e => e.Id)" Width="60px" Pin="DataGridColumnPin.Start" />
<PropertyColumn Title="Name" Property="@(e => e.Name)" Width="160px" Pin="DataGridColumnPin.Start" />
<PropertyColumn Title="City" Property="@(e => e.City)" />
<TemplateColumn Title="Actions" Width="120px" Pin="DataGridColumnPin.Right">
<TemplateColumn Title="Actions" Width="120px" Pin="DataGridColumnPin.End">
...
</TemplateColumn>
</FluentDataGrid>
Expand All @@ -63,22 +63,20 @@ property `--fluent-data-grid-pinned-background`:

## Notes

* Column resizing interacts correctly with sticky offsets — the JavaScript in
`FluentDataGrid.razor.ts` recalculates `left` / `right` values after every resize step via
`UpdatePinnedColumnOffsets`.
* Column resizing keeps pinned columns aligned as widths change.
* Virtualization and paging are fully compatible because each rendered row's cells carry the
same `position: sticky` styling regardless of which page or scroll position is active.
* In RTL layouts the browser interprets `left` / `right` according to the document direction, so
pinned columns behave correctly without additional configuration.
* RTL layouts are fully supported: start and end automatically map to the correct physical
direction based on the document's writing mode.

## Example

Demonstrates pinned (frozen) columns using `Pin="DataGridColumnPin.Left"` and `Pin="DataGridColumnPin.Right"`.
Demonstrates pinned (frozen) columns using `Pin="DataGridColumnPin.Start"` and `Pin="DataGridColumnPin.End"`.
The two leftmost columns and the Actions column remain visible while the rest scroll horizontally.

Wrap the grid in a `<div style="overflow-x: auto;">` container and give the grid a `Style="min-width: max-content;"`
so that the horizontal scroll bar appears.

Pinned columns require an explicit pixel `Width`.
Pinned columns require an explicit `Width`.

{{ DataGridPinnedColumns }}
16 changes: 9 additions & 7 deletions src/Core/Components/DataGrid/Columns/ColumnBase.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -188,21 +188,23 @@ public abstract partial class ColumnBase<TGridItem>
public string? Width { get; set; }

/// <summary>
/// Gets or sets whether this column is pinned (frozen) to the left or right edge of the grid,
/// Gets or sets whether this column is pinned (frozen) to the start or end edge of the grid,
/// so it remains visible when the user scrolls horizontally.
/// Pinned columns require an explicit <see cref="Width"/> in pixels (e.g., <c>"150px"</c>).
/// Left-pinned columns must be contiguous at the start of the column list;
/// right-pinned columns must be contiguous at the end.
/// Pinned columns require an explicit <see cref="Width"/>.
/// Sticky offsets are recomputed from rendered header widths after the grid is rendered.
/// Start-pinned columns must be contiguous at the start of the column list;
/// end-pinned columns must be contiguous at the end.
/// </summary>
[Parameter]
public DataGridColumnPin Pin { get; set; } = DataGridColumnPin.None;

/// <summary>
/// The sticky <c>left</c> or <c>right</c> CSS offset (in pixels) computed by
/// <see cref="FluentDataGrid{TGridItem}"/> when columns are collected.
/// The sticky start or end CSS offset seeded by
/// <see cref="FluentDataGrid{TGridItem}"/> when columns are collected and later updated from
/// rendered widths by JavaScript.
/// Not intended for direct use by consumers.
/// </summary>
internal double PinOffsetPx { get; set; }
internal string PinOffset { get; set; } = "0px";

/// <summary>
/// Gets or sets the minimal width of the column.
Expand Down
68 changes: 31 additions & 37 deletions src/Core/Components/DataGrid/FluentDataGrid.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -600,7 +600,7 @@ private void FinishCollectingColumns()
throw new ArgumentException("The 'HierarchicalToggle' parameter can only be set on the first column of the grid.");
}

// Validate and compute offsets for pinned columns.
// Validate pinned columns and seed their initial sticky offsets.
ValidateAndComputePinnedColumns();

// Always re-evaluate after collecting columns when using displaymode grid. A column might be added or hidden and the _internalGridTemplateColumns needs to reflect that.
Expand All @@ -624,12 +624,13 @@ private void FinishCollectingColumns()
}

/// <summary>
/// Validates the pinned-column configuration and computes the sticky pixel offsets for each
/// pinned column. Rules enforced:
/// Validates the pinned-column configuration and seeds initial sticky offsets for each
/// pinned column before JavaScript recomputes them from rendered widths after first render.
/// Rules enforced:
/// <list type="bullet">
/// <item>Pinned columns must specify an explicit pixel <c>Width</c> (e.g., <c>"150px"</c>).</item>
/// <item>Left-pinned columns must be contiguous at the beginning of the column list.</item>
/// <item>Right-pinned columns must be contiguous at the end of the column list.</item>
/// <item>Pinned columns must specify an explicit <c>Width</c>.</item>
/// <item>Start-pinned columns must be contiguous at the beginning of the column list.</item>
/// <item>End-pinned columns must be contiguous at the end of the column list.</item>
/// </list>
/// </summary>
private void ValidateAndComputePinnedColumns()
Expand All @@ -642,20 +643,20 @@ private void ValidateAndComputePinnedColumns()

ValidatePinnedColumnConstraints();

// Compute left-pin sticky offsets (cumulative left-to-right).
var leftOffset = 0.0;
foreach (var col in _columns.Where(c => c.Pin == DataGridColumnPin.Left))
// Compute start-pin sticky offsets in display order.
var startOffset = 0.0;
foreach (var col in _columns.Where(c => c.Pin == DataGridColumnPin.Start))
{
col.PinOffsetPx = leftOffset;
leftOffset += ParsePixelWidth(col.Width);
col.PinOffset = $"{startOffset.ToString(CultureInfo.InvariantCulture)}px";
startOffset += ParsePixelWidth(col.Width);
}

// Compute right-pin sticky offsets (cumulative right-to-left).
var rightOffset = 0.0;
foreach (var col in _columns.Where(c => c.Pin == DataGridColumnPin.Right).Reverse())
// Compute end-pin sticky offsets in reverse display order.
var endOffset = 0.0;
foreach (var col in _columns.Where(c => c.Pin == DataGridColumnPin.End).Reverse())
{
col.PinOffsetPx = rightOffset;
rightOffset += ParsePixelWidth(col.Width);
col.PinOffset = $"{endOffset.ToString(CultureInfo.InvariantCulture)}px";
endOffset += ParsePixelWidth(col.Width);
}
}

Expand All @@ -665,52 +666,46 @@ private void ValidateAndComputePinnedColumns()
/// </summary>
private void ValidatePinnedColumnConstraints()
{
// Width must be an explicit pixel value.
// Width must be explicitly provided for pinned columns.
foreach (var col in _columns.Where(c => c.Pin != DataGridColumnPin.None))
{
if (string.IsNullOrWhiteSpace(col.Width))
{
throw new ArgumentException(
$"Column '{col.Title ?? col.Index.ToString(CultureInfo.InvariantCulture)}' has Pin set but no Width. " +
"Pinned columns require an explicit Width in pixels (e.g., '150px').");
}

if (!col.Width!.Trim().EndsWith("px", StringComparison.OrdinalIgnoreCase))
{
throw new ArgumentException(
$"Column '{col.Title ?? col.Index.ToString(CultureInfo.InvariantCulture)}' has Pin set but Width '{col.Width}' is not in pixels. " +
"Pinned columns require an explicit Width in pixels (e.g., '150px').");
"Pinned columns require an explicit Width.");
}
}

// Left-pinned columns must be contiguous at the start: each one must be preceded by
// another left-pinned column (or be the very first column).
// Start-pinned columns must be contiguous at the start: each one must be preceded by
// another start-pinned column (or be the very first column).
for (var i = 0; i < _columns.Count; i++)
{
if (_columns[i].Pin == DataGridColumnPin.Left && i > 0 && _columns[i - 1].Pin != DataGridColumnPin.Left)
if (_columns[i].Pin == DataGridColumnPin.Start && i > 0 && _columns[i - 1].Pin != DataGridColumnPin.Start)
{
throw new ArgumentException(
$"Column '{_columns[i].Title ?? _columns[i].Index.ToString(CultureInfo.InvariantCulture)}' is left-pinned but the preceding column is not. " +
"Left-pinned columns must be contiguous at the start of the column list.");
$"Column '{_columns[i].Title ?? _columns[i].Index.ToString(CultureInfo.InvariantCulture)}' is start-pinned but the preceding column is not. " +
"Start-pinned columns must be contiguous at the start of the column list.");
}
}

// Right-pinned columns must be contiguous at the end: each one must be followed by
// another right-pinned column (or be the very last column).
// End-pinned columns must be contiguous at the end: each one must be followed by
// another end-pinned column (or be the very last column).
for (var i = 0; i < _columns.Count; i++)
{
if (_columns[i].Pin == DataGridColumnPin.Right && i < _columns.Count - 1 && _columns[i + 1].Pin != DataGridColumnPin.Right)
if (_columns[i].Pin == DataGridColumnPin.End && i < _columns.Count - 1 && _columns[i + 1].Pin != DataGridColumnPin.End)
{
throw new ArgumentException(
$"Column '{_columns[i].Title ?? _columns[i].Index.ToString(CultureInfo.InvariantCulture)}' is right-pinned but the following column is not. " +
"Right-pinned columns must be contiguous at the end of the column list.");
$"Column '{_columns[i].Title ?? _columns[i].Index.ToString(CultureInfo.InvariantCulture)}' is end-pinned but the following column is not. " +
"End-pinned columns must be contiguous at the end of the column list.");
}
}
}

/// <summary>
/// Parses a CSS pixel value string such as <c>"150px"</c> and returns the numeric value.
/// Returns <c>0</c> if the string is null, empty, or not a valid pixel value.
/// Returns <c>0</c> if the string is null, empty, or not a valid pixel value so JavaScript
/// can recompute the final sticky offsets from rendered widths after first render.
/// </summary>
private static double ParsePixelWidth(string? width)
{
Expand Down Expand Up @@ -1445,4 +1440,3 @@ private async Task ToggleExpandedAsync(TGridItem item)
}
}
}

24 changes: 12 additions & 12 deletions src/Core/Components/DataGrid/FluentDataGrid.razor.css
Original file line number Diff line number Diff line change
Expand Up @@ -100,37 +100,37 @@
/* ---- Pinned (sticky) columns ---- */

/* Background: keeps content from showing through when scrolling. Override --fluent-data-grid-pinned-background to theme. */
.fluent-data-grid th.col-pinned-left,
.fluent-data-grid th.col-pinned-right {
.fluent-data-grid th.col-pinned-start,
.fluent-data-grid th.col-pinned-end {
background-color: var(--colorNeutralBackground1);
}

.fluent-data-grid td.col-pinned-left,
.fluent-data-grid td.col-pinned-right {
.fluent-data-grid td.col-pinned-start,
.fluent-data-grid td.col-pinned-end {
background-color: var(--fluent-data-grid-pinned-background);
}

/* Visual separator on the trailing edge of the last left-pinned column */
.fluent-data-grid td:nth-last-child(1 of .col-pinned-left) {
/* Visual separator on the trailing edge of the last start-pinned column */
.fluent-data-grid td:nth-last-child(1 of .col-pinned-start) {
border-inline-end: var(--strokeWidthThin) solid var(--colorNeutralStroke1);
}

/* Visual separator on the leading edge of the first right-pinned column */
.fluent-data-grid td:nth-child(1 of .col-pinned-right) {
/* Visual separator on the leading edge of the first end-pinned column */
.fluent-data-grid td:nth-child(1 of .col-pinned-end) {
border-inline-start: var(--strokeWidthThin) solid var(--colorNeutralStroke1);
}

/* Pinned header cells must stack above pinned data cells (z-index: 1 applied inline by C#).
For UseMenuService=false the inline z-index: 5 is absent, so we provide a floor here.
The sticky-header row needs the highest value to beat both the sticky-header row z-index (2)
and the pinned data cell z-index (1). */
.fluent-data-grid th.col-pinned-left,
.fluent-data-grid th.col-pinned-right {
.fluent-data-grid th.col-pinned-start,
.fluent-data-grid th.col-pinned-end {
z-index: 2;
}

.fluent-data-grid tr[row-type='sticky-header'] > th.col-pinned-left,
.fluent-data-grid tr[row-type='sticky-header'] > th.col-pinned-right {
.fluent-data-grid tr[row-type='sticky-header'] > th.col-pinned-start,
.fluent-data-grid tr[row-type='sticky-header'] > th.col-pinned-end {
background-color: var(--colorNeutralBackground4);
z-index: 4;
}
Expand Down
Loading