NovaRibbon 1.0.0-preview.2
dotnet add package NovaRibbon --version 1.0.0-preview.2
NuGet\Install-Package NovaRibbon -Version 1.0.0-preview.2
<PackageReference Include="NovaRibbon" Version="1.0.0-preview.2" />
<PackageVersion Include="NovaRibbon" Version="1.0.0-preview.2" />
<PackageReference Include="NovaRibbon" />
paket add NovaRibbon --version 1.0.0-preview.2
#r "nuget: NovaRibbon, 1.0.0-preview.2"
#:package NovaRibbon@1.0.0-preview.2
#addin nuget:?package=NovaRibbon&version=1.0.0-preview.2&prerelease
#tool nuget:?package=NovaRibbon&version=1.0.0-preview.2&prerelease
<div align="center">
NovaRibbon
A Microsoft 365 ribbon for WPF.
The Word 365 window chrome — adaptive ribbon, KeyTips, contextual tabs, Backstage, quick access, galleries — on .NET 10, with no runtime dependencies.
Quick start · Ribbon · MVVM · Accessibility · Türkçe
<img src="https://raw.githubusercontent.com/shadesofdeath/NovaRibbon/main/assets/ribbon-light.png" alt="NovaRibbon in the light theme" width="900">
<img src="https://raw.githubusercontent.com/shadesofdeath/NovaRibbon/main/assets/ribbon-dark.png" alt="NovaRibbon in the dark theme" width="900">
<sub>Classic layout, light and dark. Below: the Simplified one-row layout.</sub>
<img src="https://raw.githubusercontent.com/shadesofdeath/NovaRibbon/main/assets/ribbon-simplified.png" alt="NovaRibbon in the Simplified layout" width="900">
</div>
Why
WPF has no modern ribbon. Microsoft's own RibbonControlsLibrary was abandoned years ago and still looks like Office 2007. The commercial suites are excellent and cost money per seat.
NovaRibbon is the third option: the current Microsoft 365 ribbon — the flat one, with the search box in the title bar, the Simplified layout and the display-options menu — as a free, MIT-licensed control library.
It is not a skin over RibbonControlsLibrary. Every control is written from scratch against measured Word 365 metrics: a 96 DIP ribbon body, 24 DIP input rows, the 32/16 icon ladder, the 16/13/12.5/12/11.5/11/10 pt type scale, Segoe UI throughout.
What you get
| Adaptive groups | Groups shrink Large → Medium → Small → Collapsed as the window narrows, in the order you choose. A collapsed group becomes one button with a drop-down body. |
| Two layouts | Classic (two rows) and Simplified (one icon row), independent of the three display modes — exactly as Word separates them. |
| KeyTips | Three levels: Alt → tabs → commands → inside the drop-down. Badges reach into Backstage. |
| Contextual tabs | Coloured tab groups that appear with a selection and auto-select their first tab. |
| Quick access toolbar | Right-click any command → Add to Quick Access Toolbar. Drag to reorder, overflow chevron, above or below the ribbon. Zero application code. |
| Customize dialog | Word's two-pane Customize the Ribbon dialog, built in. Cancel really cancels. |
| Persistence | Versioned JSON for the whole layout — quick access, order, visibility, display mode. Loading never throws. |
| MVVM at every level | Bind tabs, groups and commands. A bound ribbon behaves identically to a hand-written one. |
| Backstage | The File view, with page entries, command entries, separators and headers. |
| Three themes | Light, Dark, and a High Contrast palette bound entirely to SystemColors. Switching is instant. |
| Right-to-left | Follows the ambient FlowDirection. Nothing to configure. |
| Accessibility | 24 automation peers with real patterns, a documented name-resolution order, and a two-tone focus ring. |
| Icons included | Fluent UI System Icons, baked into the assembly at build time. No network at runtime, no font to install. |
Requirements
- .NET 10 (
net10.0-windows) - Windows — this is WPF
- Visual Studio 2026, Rider, or the
dotnetCLI
No other NuGet package is pulled in. The dependency list is empty on purpose.
Install
dotnet add package NovaRibbon --prerelease
or in your .csproj:
<PackageReference Include="NovaRibbon" Version="1.0.0-preview.2" />
Quick start
Three steps. This is the whole thing.
1. Merge the dictionaries
In App.xaml — the palette first, the control styles second:
<Application x:Class="MyApp.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
StartupUri="MainWindow.xaml">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="pack://application:,,,/NovaRibbon;component/Themes/Light.xaml" />
<ResourceDictionary Source="pack://application:,,,/NovaRibbon;component/Themes/Nova.xaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>
2. Derive your window from NovaWindow
<ui:NovaWindow x:Class="MyApp.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.novaribbon.dev/2026/xaml"
Title="My Document" Width="1100" Height="700">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<ui:TitleBar Grid.Row="0" Title="My Document">
<ui:TitleBar.LeftContent>
<ui:QuickAccessToolBar x:Name="QuickAccess">
<ui:QuickAccessButton Symbol="Save" ToolTip="Save" />
<ui:QuickAccessButton Symbol="Undo" ToolTip="Undo" />
</ui:QuickAccessToolBar>
</ui:TitleBar.LeftContent>
<ui:TitleBar.CenterContent>
<ui:SearchBox Width="420" PlaceholderText="Search" />
</ui:TitleBar.CenterContent>
</ui:TitleBar>
<ui:Ribbon Grid.Row="1" x:Name="MainRibbon"
FileTabHeader="File"
QuickAccessHost="{Binding ElementName=QuickAccess}">
<ui:RibbonTab Header="Home" ui:KeyTip.Text="H">
<ui:RibbonGroup Header="Clipboard" Symbol="Paste"
ShrinkPriority="0" MinVariant="Small"
ShowDialogLauncher="True">
<ui:Stack Orientation="Horizontal" Spacing="2">
<ui:RibbonSplitButton Label="Paste" Symbol="Paste"
Size="Large" ui:KeyTip.Text="V" />
<ui:Stack Orientation="Vertical" Spacing="2">
<ui:RibbonButton Label="Cut" Symbol="Cut" Size="Small" />
<ui:RibbonButton Label="Copy" Symbol="Copy" Size="Small"
ui:RibbonId.Id="Home.Clipboard.Copy" />
</ui:Stack>
</ui:Stack>
</ui:RibbonGroup>
<ui:RibbonGroup Header="Font" Symbol="FontColor" ShrinkPriority="1">
<ui:Stack Orientation="Vertical" Spacing="5">
<ui:Stack Orientation="Horizontal" Spacing="4">
<ui:RibbonFontFamilyComboBox Width="132" SelectedFontFamily="Segoe UI" />
<ui:RibbonFontSizeComboBox Width="56" SelectedSize="11" />
</ui:Stack>
<ui:Stack Orientation="Horizontal" Spacing="2">
<ui:RibbonToggleButton Symbol="Bold" ToolTip="Bold" />
<ui:RibbonToggleButton Symbol="Italic" ToolTip="Italic" />
<ui:RibbonToggleButton Symbol="Underline" ToolTip="Underline" />
</ui:Stack>
</ui:Stack>
</ui:RibbonGroup>
</ui:RibbonTab>
<ui:RibbonTab Header="Insert" ui:KeyTip.Text="N" />
</ui:Ribbon>
<ui:NovaStatusBar Grid.Row="3">
<ui:NovaStatusBar.RightContent>
<ui:ZoomControl Minimum="50" Maximum="200" Zoom="100" />
</ui:NovaStatusBar.RightContent>
</ui:NovaStatusBar>
</Grid>
</ui:NovaWindow>
3. Change the base class in code-behind
dotnet new wpf gives you a MainWindow that derives from Window. Change the base
class, and keep the namespace matching the x:Class in the XAML:
using NovaRibbon.Controls;
namespace MyApp;
public partial class MainWindow : NovaWindow
{
public MainWindow() => InitializeComponent();
}
Run it. You have an adaptive ribbon, working KeyTips (press Alt), a right-click menu that really adds commands to the quick access toolbar, and a title bar with search.
<img src="https://raw.githubusercontent.com/shadesofdeath/NovaRibbon/main/assets/quickstart.png" alt="What the quick start produces" width="900">
<sub>That is a screenshot of exactly the code above, nothing else added.</sub>
Why merge two dictionaries instead of one?
Themes/Generic.xamlexists but carries only the styles, not the palette — the palette keys are plain strings rather thanComponentResourceKey, so WPF's implicit theme lookup cannot resolve them. MergingLight.xaml(orDark.xaml) explicitly is the supported path. See docs/theming.md.
Table of contents
- Themes
- Title bar and search
- Language
- The ribbon
- Ribbon content controls
- Backstage, status bar and mini toolbar
- Command identity
- Quick access and persistence
- Customizing the ribbon
- MVVM and data binding
- Right-to-left
- Accessibility
- Control list
- Icons
- Project layout
- Building from source
- Contributing
Themes
using NovaRibbon.Appearance;
ThemeManager.Apply(ApplicationTheme.Light); // light
ThemeManager.Apply(ApplicationTheme.Dark); // dark
ThemeManager.Apply(ApplicationTheme.HighContrast); // the Windows contrast scheme
ThemeManager.Apply(ApplicationTheme.System); // let Windows decide
ThemeManager.UseSystemAccentColor = true; // adopt the OS accent colour
Every colour resolves through DynamicResource, so a switch repaints the running app instantly — no window recreation.
ThemeManager.ActualTheme returns the palette that is actually applied: Light, Dark or HighContrast. It never returns System, because System is a request, not a result.
Precedence. An explicit Apply(Light | Dark | HighContrast) always wins and the OS setting is ignored. Apply(System) hands the decision to Windows: high contrast first, then the light/dark registry preference. If you want accessibility to be able to override a fixed brand theme, call Apply(System) at startup — an app that says Apply(Light) will never see high contrast.
While the high-contrast palette is applied the OS colours keep being watched: moving from one contrast scheme to another reloads the palette. ThemeManager.Toggle() does nothing in that state — a "dark mode" button silently dropping a user out of their accessibility palette is not acceptable.
Full key list in docs/theming.md.
Title bar and search
The title bar is 44 DIP tall and has three slots: LeftContent (usually the quick access toolbar), CenterContent, and RightContent (left of the window buttons).
<ui:TitleBar Title="{Binding Title, RelativeSource={RelativeSource AncestorType=Window}}">
<ui:TitleBar.CenterContent>
<ui:SearchBox Width="420"
PlaceholderText="Search"
SearchCommand="{Binding SearchCommand}"
Suggestions="{Binding SearchSuggestions}" />
</ui:TitleBar.CenterContent>
</ui:TitleBar>
With no CenterContent the title text is centred instead. As the window narrows the SearchBox collapses to just its magnifier button on its own (AutoCompact, on by default); IsCompact drives it manually.
Language
The library's own strings — context menus, window button tooltips, colour names — come from resource files. The neutral culture is English; a tr satellite assembly ships in the box. Do nothing and CultureInfo.CurrentUICulture applies.
LocalizationManager.Culture = new CultureInfo("tr"); // pick one
LocalizationManager.Culture = null; // back to the system culture
Changes apply immediately — visible text updates without recreating the window. You can use the library's strings in your own XAML:
<MenuItem Header="{ui:Localize RibbonCustomize}" />
To add a language, create Strings.<culture>.resx and translate every key from the neutral file; a test enforces that the key sets match.
The ribbon
<ui:Ribbon FileTabHeader="File" FileTabCommand="{Binding OpenBackstage}">
<ui:Ribbon.ContextualTabGroups>
<ui:RibbonContextualTabGroup x:Name="TableTools"
Header="TABLE TOOLS"
AccentBrush="#B1468E"
IsActive="{Binding IsTableSelected}" />
</ui:Ribbon.ContextualTabGroups>
<ui:RibbonTab Header="Home" ui:KeyTip.Text="H">
<ui:RibbonGroup Header="Clipboard" Symbol="Paste"
ShrinkPriority="0" MinVariant="Small"
ShowDialogLauncher="True">
</ui:RibbonGroup>
</ui:RibbonTab>
<ui:RibbonTab Header="Design" ContextualTabGroup="{Binding ElementName=TableTools}" />
</ui:Ribbon>
Adaptive groups
As the window narrows, groups shrink in stages the way Office does: Large → Medium → Small → Collapsed. ShrinkPriority sets the order (higher shrinks first) and MinVariant sets how far a group may go. A Collapsed group becomes a single button whose body opens in a drop-down.
Simplified layout and display options
Word has two ribbon layouts and three display modes, and the user switches between them independently. NovaRibbon keeps them as two separate properties, because a ribbon can be both simplified and collapsed at once:
| Property | Values | Effect |
|---|---|---|
Ribbon.LayoutMode |
Classic · Simplified |
The classic two-row card (96 DIP) or a single icon-only row (~40 DIP). |
Ribbon.DisplayMode |
ShowTabsAndCommands · ShowTabsOnly · FullScreen |
How much of the ribbon is visible. |
Ribbon.IsMinimized |
bool |
A two-way shortcut for the middle two states. Full screen is a third state and reads false there. |
The Display Options button at the right end of the tab strip (ShowDisplayOptionsButton, default true) opens all five choices in one menu. The collapse chevron stays where it is — Word has both side by side; the chevron is the one-click toggle, the button shows everything including full screen and Classic/Simplified.
To drive the same choices from your own shell, the commands are ready:
<ui:RibbonButton Label="Simplified"
Command="{x:Static ui:RibbonDisplayCommands.UseSimplifiedLayout}"
CommandTarget="{Binding ElementName=MainRibbon}" />
RibbonDisplayCommands members: ShowFullScreen, ShowTabsOnly, ShowTabsAndCommands, UseClassicLayout, UseSimplifiedLayout. CommandTarget is required — the bindings live on the ribbon, and the route from a button reaches them only when you name the target.
In the Simplified layout:
Group names and dialog launchers are hidden and the name row disappears entirely. The card height comes from a separate
SimplifiedRibbonBodyHeight(default40) rather thanRibbonBodyHeight. Two properties is deliberate: 96 is an arithmetic result of the large-button ladder plus a 20 DIP name row, and the simplified row has neither — one shared property would lose the app's classic value the moment a user switched.The
Variantyour app set is never touched. The stage actually on screen is reported by the read-onlyRibbonGroup.EffectiveVariant(alwaysSmallin Simplified). Going back to Classic therefore has nothing to restore; the value was never damaged.A group too tall for the row is not clipped — it overflows. A two-row body written for Classic (stacked combo boxes, gallery tiles) does not fit in 40 DIP; the panel discovers this by measuring the group's natural size and moves it out of the row. Showing half a group is the worst option.
The library cannot reflow a consumer's body into one line, but the consumer can supply that line —
RibbonGroup.SimplifiedContent:<ui:RibbonGroup Header="Font"> <ui:RibbonGroup.SimplifiedContent> <ui:Stack Orientation="Horizontal" Spacing="2"> <ui:RibbonToggleButton Symbol="Bold" ToolTip="Bold" /> <ui:RibbonToggleButton Symbol="Italic" ToolTip="Italic" /> </ui:Stack> </ui:RibbonGroup.SimplifiedContent> <ui:Stack Orientation="Vertical" Spacing="5"></ui:Stack> </ui:RibbonGroup>Without it the group is measured with its classic body: drawn if it fits, overflowed if it does not.
RibbonGroup.EffectiveContentreports which body is being drawn, and the variant is always written to the body that is actually drawn.Commands from groups that do not fit fall into the
…overflow menu at the end of the row. Menu rows are generated from each command's portable definition and invoke the original; the group itself does not move.RibbonGroupPanel.IsOverflowingandRibbonGroupPanel.GetIsOverflowItem(element)expose the state.Adaptive shrinking never runs: every group is already at its smallest stage.
Input controls (combo box, text box, spinner) are narrowed for the row. They are skipped in the overflow menu: a combo box cannot be drawn in a 26 DIP menu row, and a dead row is worse than no row.
KeyTips, contextual tabs, quick access and
Ribbon.Commandsall work unchanged.
Full-screen mode hides the tab strip and the body, leaving a thin band that calls the ribbon back (IsFullScreenRevealed). Clicking outside the ribbon or invoking a command dismisses the temporary reveal; Esc leaves the mode.
Both are user preferences and are part of the saved layout — see Saving and restoring the layout.
KeyTips
Alt shows the tab badges; picking a tab shows that tab's command badges; picking a command that owns a drop-down opens it and shows the badges inside the panel. Esc steps back one level (and closes the panel it opened at the third level), Alt leaves the mode entirely. Assign badges with ui:KeyTip.Text="V".
The three levels are readable through Ribbon.KeyTipLevel: None → Tabs → Commands → DropDown. Built-in controls that enter the third level: a collapsed RibbonGroup, RibbonMenuButton, RibbonGallery, and a RibbonSplitButton with a DropDownMenu. An app with its own drop-down surface overrides Ribbon.TryOpenKeyTipDropDown; to add its own elements to the target set it overrides Ribbon.CollectKeyTips:
public sealed class AppRibbon : Ribbon
{
protected override void CollectKeyTips(RibbonKeyTipLevel level, IList<KeyTipTarget> targets)
{
base.CollectKeyTips(level, targets);
if (level == RibbonKeyTipLevel.Commands)
targets.Add(new KeyTipTarget(_myPanelButton, "U"));
}
}
A collapsed group with no ui:KeyTip.Text gets a letter derived from its header: without it, every command inside a letterless collapsed group would silently drop out of Alt navigation.
The File tab's badge reaches into Backstage: when the overlay opens the rail entries get badges, the ribbon's badges hide (they are underneath), and Esc closes the overlay and returns to the tab level. Tab is trapped inside the overlay while it is open, and focus returns where it came from on close.
Other ribbon behaviour
- When tabs do not fit, the strip scrolls: a chevron appears at each end, one step is exactly one tab, the chevron at the end dims (it does not vanish), and the selected tab — selected by code, mouse, keyboard or KeyTip — is always brought into view. Measurement re-runs when contextual tabs appear and disappear. Scrolling was chosen over an overflow menu because tab order is muscle memory and a menu splits it in two.
- Double-clicking a tab header collapses and restores the ribbon.
- The mouse wheel over the ribbon moves between tabs (Word behaviour).
- Empty space in the tab strip behaves like the title bar: drag to move the window, double-click to maximise.
- Right-clicking the ribbon: Add to / Remove from Quick Access Toolbar · Show Quick Access Toolbar · Show Below / Above the Ribbon · Customize the Ribbon · Collapse the Ribbon.
- The dialog launcher at a group's bottom-right (
ShowDialogLauncher) raises a command or event.
Ribbon settings
| Property | Default | Effect |
|---|---|---|
RibbonBodyHeight |
96 |
The body is a fixed height, so content does not jump when the tab changes. Pass double.NaN to size to content again. |
ShowContextualHeaders |
false |
The coloured band above contextual tabs. Microsoft 365 removed it; set true for the classic look. |
SimplifiedRibbonBodyHeight |
40 |
Fixed body height in the Simplified layout. |
LayoutMode |
Classic |
Classic / Simplified. An inherited attached property. |
DisplayMode |
ShowTabsAndCommands |
How much of the ribbon shows; mapped two-way to IsMinimized. |
ShowDisplayOptionsButton |
true |
The Display Options button at the right end of the tab strip. |
ShowHelpButton |
false |
The help button at the right end of the tab strip. |
ShowCollapseButton |
true |
The collapse chevron. |
CanCustomizeRibbon |
true once loaded |
The "Customize the Ribbon" context menu item. |
ShowsBuiltInCustomizeDialog |
true |
Turn off to have the ribbon only raise CustomizeRequested. |
When a contextual group activates, its first tab is selected automatically (RibbonContextualTabGroup.AutoSelectOnActivate); when it closes, selection returns to the first ordinary tab.
To generate tabs, groups and commands from a view model instead of writing XAML, see MVVM and data binding.
Ribbon content controls
A group holds more than buttons. All of these are at the 24 DIP / 12 pt ribbon scale, implement IRibbonCommandSource, accept ui:KeyTip.Text and a ScreenTip, work from the keyboard, and have an answer when the group shrinks — none of them blocks a group from getting smaller.
Editable combo box
With IsEditable="True" the RibbonComboBox shows a real text box: Text is two-way, Enter commits, Escape reverts to the last committed value, and leaving the box commits — Word's font-box behaviour.
<ui:RibbonComboBox Width="120" IsEditable="True"
Text="{Binding Unit, Mode=TwoWay}" />
From code you can call CommitText() / RevertText() and read CommittedText. A derived control validates via protected override string? CoerceCommittedText(string text); returning null means "invalid" and the box reverts.
Font family and size boxes
<ui:RibbonFontFamilyComboBox Width="132" MaxRecentFonts="5"
SelectedFontFamily="{Binding FontFamily, Mode=TwoWay}">
<ui:RibbonFontFamilyComboBox.ThemeFonts>
<FontFamily>Segoe UI</FontFamily>
<FontFamily>Cambria</FontFamily>
</ui:RibbonFontFamilyComboBox.ThemeFonts>
</ui:RibbonFontFamilyComboBox>
<ui:RibbonFontSizeComboBox Width="56" SelectedSize="{Binding FontSize, Mode=TwoWay}" />
RibbonFontFamilyComboBox draws every row in its own font and splits the list into three sections: ThemeFonts, RecentFonts, and all fonts. Section headers are not list items — they cannot be selected or reached with arrow keys. The list is built on first interaction and the drop-down is virtualized, so the cost of per-row font rendering scales with visible rows, not with list length. Use FontSource to supply your own set instead of the system list. RecentFonts updates when the user picks a font; assigning SelectedFontFamily from code does not touch it.
RibbonFontSizeComboBox ships with Word's list (StandardSizes: 8 … 72). A size not in the list (13) is accepted, a number outside Minimum – Maximum is clamped and the clamped value is written back to a two-way binding source, and non-numeric text reverts to the last committed size. The default ceiling is 409.5 pt; write Maximum="1638" for Word's dialog limit.
Input primitives
<ui:Stack Orientation="Horizontal" Spacing="0">
<ui:RibbonLabel Content="_Columns:" Target="{Binding ElementName=Columns}" />
<ui:RibbonSpinner x:Name="Columns" Minimum="1" Maximum="10" Step="1"
Value="{Binding ColumnCount, Mode=TwoWay}" />
</ui:Stack>
<ui:RibbonTextBox Width="150" Label="Find:" PlaceholderText="Search the document…"
Text="{Binding Query, Mode=TwoWay}" />
<ui:RibbonRadioButton Content="Portrait" GroupName="Orientation"
IsChecked="{Binding IsPortrait, Mode=TwoWay}" />
| Control | What it is | When the group shrinks |
|---|---|---|
RibbonTextBox |
Labelled text entry; Label, PlaceholderText, HasText. Enter commits, Escape reverts. |
Medium 110, Small 84 DIP cap; the label hides at Small |
RibbonSpinner |
Ribbon-scale numeric stepper. Value, range and step logic come from NumberBox. |
Medium 60, Small 50 DIP cap |
RibbonRadioButton |
The mirror of RibbonCheckBox: same row height, same full-row highlight. |
Padding tightens, the label stays |
RibbonLabel |
The caption for the input beside it. Content="_Columns:" + Target wires the access key. |
Hidden entirely at Small |
RibbonSpinner derives from NumberBox rather than reimplementing it: clamping, NaN handling, arrow keys and the mouse wheel all come from there. Only the metrics differ — NumberBox is 28 DIP for its content area and its arrow halves are crushed when squeezed to 24.
RibbonCheckBox and RibbonRadioButton are deliberately not pinned to 24 DIP: a Word group is at most three rows, a 96 DIP card leaves 67 DIP for the body, and three 24 DIP rows would clip the third.
A button that only opens a panel
RibbonMenuButton is Word's Table, Bullets and Text Effects button: there is no action half, the whole button opens the panel. Click never fires and Command is never executed. Its face comes from the same shared templates as RibbonButton, so the four sizes, the two-line label box, arrow placement, disabled icon dimming and the fall-back-to-content behaviour when no Symbol is set are all identical.
<ui:RibbonMenuButton Label="Table" Size="Large" Symbol="Table" ui:KeyTip.Text="TB">
<ui:RibbonMenuButton.DropDownItems>
<ui:RibbonMenuItem Header="Insert Table" Symbol="Table" InputGestureText="Ctrl+T"
Description="Inserts an empty table into the document."
Command="{Binding InsertTable}" />
<Separator />
<ui:RibbonMenuItem Header="Repeat Header Row" IsCheckable="True" IsChecked="True" />
</ui:RibbonMenuButton.DropDownItems>
</ui:RibbonMenuButton>
RibbonMenuItem is Word's rich menu row: icon column, header, two-line Description, InputGestureText, check mark and submenu arrow. The column layout uses the same SharedSizeGroup names as a plain MenuItem, so the two kinds of row stay aligned side by side in one menu — and plain MenuItem is not changed at all.
The panel does not have to be a menu: DropDownContent takes free content (Word's table grid). Free content closes itself with CloseDropDown(); menu rows and gallery items close the panel on their own.
<ui:RibbonMenuButton x:Name="TableButton" Label="Table" Symbol="Table">
<ui:RibbonMenuButton.DropDownContent>
<ItemsControl ItemsSource="{Binding TableCells}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><UniformGrid Columns="8" /></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
</ui:RibbonMenuButton.DropDownContent>
</ui:RibbonMenuButton>
A copy added to the quick access toolbar opens the same panel below itself; you never end up with an inert copy of a button that has no command.
Gallery panel
RibbonGallery's "open the gallery" button redraws the items the ribbon is showing in a multi-column panel. You do not write a second item list: same ItemsSource, same ItemTemplate, same selection.
<ui:RibbonGallery Width="150" DropDownColumns="3"
ItemsSource="{Binding Styles}"
ItemTemplate="{StaticResource StyleTile}"
SelectedItem="{Binding SelectedStyle, Mode=TwoWay}"
PreviewRequested="OnPreview" PreviewCanceled="OnPreviewCanceled">
<ui:RibbonGallery.DropDownHeader>
<TextBlock Text="STYLES IN THIS DOCUMENT" />
</ui:RibbonGallery.DropDownHeader>
<ui:RibbonGallery.DropDownFooter>
<ui:NovaButton Content="Save Selection as a New Quick Style"
Command="{Binding SaveStyle}" />
</ui:RibbonGallery.DropDownFooter>
</ui:RibbonGallery>
- Selection is two-way: picking in the panel updates the ribbon and vice versa.
- Live preview comes from the panel too;
PreviewRequested/PreviewCanceledbubble from the same gallery, so there is no second subscription. - The scroll arrows lock at the ends of the strip and scroll exactly one row.
- Keyboard: arrows move between tiles and raise a preview at every step, Enter commits, Esc returns to the selection the session started with.
- For a custom panel, supply
DropDownContent; it replaces the default projection entirely.
If you authored your tiles directly as <ui:RibbonGalleryItem> the panel stays empty: a UI element cannot have two parents, so the same tiles cannot be drawn in both places. Use ItemsSource (and read DropDownItemsSource to see what the panel actually draws) or supply DropDownContent.
Backstage, status bar and mini toolbar
Backstage entry kinds
Backstage is a TabControl and BackstageItem is a TabItem — every entry is a page. Word's File menu has three kinds of entry, and all three are here:
<ui:Backstage Grid.Row="0" Grid.RowSpan="4" IsOpen="{Binding IsBackstageOpen, Mode=TwoWay}">
<ui:BackstageItem Header="Info" Symbol="Comment" IsSelected="True">
<TextBlock Text="Info" />
</ui:BackstageItem>
<ui:BackstageSeparatorItem />
<ui:BackstageHeaderItem Header="Document" />
<ui:BackstageCommandItem Header="Save" Symbol="Save"
Command="{Binding SaveCommand}"
ui:RibbonId.Id="File.Save" />
</ui:Backstage>
| Kind | Behaviour |
|---|---|
BackstageItem |
Navigates to a page; the page is its Content. |
BackstageCommandItem |
Raises Click, runs Command, and closes the view if ClosesBackstage (default true). Selection stays on the last page. |
BackstageSeparatorItem |
A line. Not selectable, skipped by the keyboard. |
BackstageHeaderItem |
A section header. Not selectable. |
A command entry runs on Enter and Space; focusing it does not run it — the inherited TabItem behaviour is suppressed, otherwise arrowing down the rail would accidentally run every command it passed.
Status bar: zoom and view mode
NovaStatusBar can be empty — nothing is mandatory. Both of these are real controls:
<ui:NovaStatusBar.RightContent>
<StackPanel Orientation="Horizontal">
<ui:ViewModeSelector SelectedValuePath="Tag"
SelectedValue="{Binding ViewMode, Mode=TwoWay}">
<ui:ViewModeItem Symbol="Ruler" Tag="Reading" ToolTip="Read mode" />
<ui:ViewModeItem Symbol="GridLines" Tag="Print" IsSelected="True" />
</ui:ViewModeSelector>
<ui:ZoomControl Minimum="50" Maximum="200" SliderWidth="88"
SnapPoints="50,75,100,125,150,200"
Zoom="{Binding Zoom, Mode=TwoWay}" />
</StackPanel>
</ui:NovaStatusBar.RightContent>
ZoomControl: Zoom (percent), Minimum, Maximum, Step, SnapPoints, SnapTolerance, ShowButtons, ShowPercent, SliderWidth, read-only ZoomText, ZoomChanged, and PercentClick / PercentCommand for clicking the percentage label (Word opens the Zoom dialog there). Keyboard: arrows step once, PageUp / PageDown five, Home / End to the ends; Ctrl+wheel zooms. The control publishes a real RangeValue pattern.
Mini toolbar
Word's floating format bar. The library does not know about text selection; it knows how to appear next to a point:
<ui:NovaTextBox ui:MiniToolBarService.MiniToolBar="{Binding ElementName=FormatBar}" />
<ui:MiniToolBar x:Name="FormatBar">
<ui:RibbonToggleButton Symbol="Bold" ToolTip="Bold" ui:RibbonGroup.Variant="Small" />
<ui:RibbonSeparator />
<ui:RibbonButton Symbol="Copy" ToolTip="Copy" ui:RibbonGroup.Variant="Small" />
</ui:MiniToolBar>
The attached property needs no code-behind: selecting text or right-clicking shows the bar (it does not open on right-click if the element has its own ContextMenu — two popups must not race for one press). From code: Show(Point), Show(FrameworkElement), Show(UIElement, Point) and Hide(). The bar sits semi-transparent (InactiveOpacity), becomes opaque on hover or focus (IsActive), and closes on outside click and Esc.
FocusOnShow defaults to true, a deliberate departure from Word: a floating command surface a keyboard user cannot reach is an accessibility defect, and Esc returns focus where it came from. Write False for Word's leave-the-caret behaviour.
Command identity
To write the quick access toolbar to disk, customize the ribbon, or show a command in a search result, the command needs a name that is independent of the object reference and stable across sessions. ui:RibbonId.Id is that name:
<ui:RibbonButton ui:RibbonId.Id="Home.Clipboard.Copy"
Label="Copy" Symbol="Copy" Size="Small" />
The id only has to be unique within the application; dotted Tab.Group.Command naming avoids collisions in apps that host add-ins. Commands without an id work fine — they just cannot be persistently customized.
The registry: Ribbon.Commands
Every ribbon keeps its own registry; you do not create one.
FrameworkElement? element = MainRibbon.Commands.Find("Home.Clipboard.Copy");
RibbonCommandInfo? command = MainRibbon.Commands.FindCommand("Home.Clipboard.Copy");
foreach (var info in MainRibbon.Commands.EnumerateCommands())
Console.WriteLine($"{info.Id} · {info.GetDisplayText()} · {info.Kind}");
| Member | What it does |
|---|---|
Find(id) |
The element carrying the id, or null. |
FindCommand(id) |
The element's portable definition (RibbonCommandInfo). |
EnumerateCommands() |
Every named command in the ribbon, in declaration order. |
Ids |
The registered ids. |
Changed |
Raised when the id set changes (add, remove, element destroyed). |
Ribbon.DuplicateCommandId |
Raised when two elements declare the same id; the first registration wins. |
The registry finds commands in tabs that are not selected: the scan walks the logical tree, because Ribbon is a TabControl and the visual tree only contains the selected tab's content. Elements are held weakly — the registry keeps no control alive, and entries for destroyed elements drop out on their own. For long-lived storage, save the Id, never the control.
The portable command definition
Seven ribbon controls (RibbonButton, RibbonToggleButton, RibbonSplitButton, RibbonColorButton, RibbonCheckBox, RibbonComboBox, RibbonGallery) implement IRibbonCommandSource and project themselves as a RibbonCommandInfo: Id, Label, Content, Symbol, SymbolKey, Command, CommandParameter, ToolTip, KeyTip, Kind, IsEnabled and GetDisplayText(). To redraw a command on another surface you never need its concrete type; Kind says which control to produce (Button, ToggleButton, SplitButton, ColorButton, CheckBox, ComboBox, Gallery, Other). Implement the interface on your own control and it joins the same surfaces.
IsEnabled is the live enabled state, including CanExecute on a bound ICommand. Source is held weakly, so the source control can be destroyed while you hold the definition.
Quick access and persistence
Right-clicking a ribbon command and choosing "Add to Quick Access Toolbar" really adds it. There is no application code to write; the only requirement is binding a quick access surface to the ribbon:
<ui:TitleBar>
<ui:TitleBar.LeftContent>
<ui:QuickAccessToolBar x:Name="QuickAccess">
<ui:QuickAccessButton Label="Save" Symbol="Save" ToolTip="Save" />
</ui:QuickAccessToolBar>
</ui:TitleBar.LeftContent>
</ui:TitleBar>
<ui:Ribbon x:Name="MainRibbon" QuickAccessHost="{Binding ElementName=QuickAccess}">
That one binding gives you:
- Add / Remove. When the command is already on the bar the context menu shows "Remove from Quick Access Toolbar" instead — like Word, only one of the two is ever visible.
- The customize menu. The chevron at the right of the bar opens the current contents as a checkable list; unchecking removes an added command, or hides one declared in XAML. The toolbar builds this menu itself; no ribbon required.
- Position. "Show Below the Ribbon" moves the bar to its own row under the ribbon; "Show Above the Ribbon" puts it back in the title bar.
- Overflow. Commands past the space the title bar can spare drop into a
»list; the search box and window buttons never move.
CanCustomizeQuickAccess defaults to true. On a ribbon with no host that has not explicitly set the flag the menu items stay hidden: hiding the command beats showing a dead menu row that does nothing.
Drag to reorder
CanReorderItems defaults to true: hold a button, drag it sideways, a vertical line shows where it will land, and it lands there. The same move works from the keyboard with Ctrl+Left / Ctrl+Right — if dragging were the only way, the feature would be mouse-only. Both paths write through IQuickAccessHost.Move, so the new order enters CommandIds and is saved with RibbonLayout; there is no second path. Declared buttons are excluded: they always stay first and reordering does not touch them.
Declared buttons vs added commands
QuickAccessButton elements you write in XAML are declared items. They always stay first, never enter CommandIds (otherwise they would be saved and appear twice on the next launch, once from XAML and once from the saved layout), and Clear() does not touch them — removing everything is asked for explicitly with Clear(includeDeclaredItems: true). Give a declared item a ui:RibbonId.Id and the same command cannot be added a second time; "Remove" hides it rather than deleting it, and "Add" brings the same element back.
Taking over the behaviour: e.Handled
AddToQuickAccessRequested and RemoveFromQuickAccessRequested are always raised first; the ribbon performs the built-in action only if no listener set Handled. This is the single hook for your own rule:
private void OnAddToQuickAccessRequested(object sender, RoutedEventArgs e)
{
if (e is not RibbonCommandRequestedEventArgs { Command: { } command })
return;
if (command.Kind == RibbonCommandKind.CheckBox)
{
e.Handled = true; // built-in add is skipped, the decision is yours
return;
}
// Handled not set: the ribbon adds the command itself.
}
The event data is a RibbonCommandRequestedEventArgs. Source — as bubbling requires — is the ribbon itself; which command was right-clicked is in TargetElement and Command.
Position
MainRibbon.QuickAccessToolBarPosition = QuickAccessToolBarPosition.BelowRibbon;
The ribbon takes the toolbar out of its slot (usually TitleBar.LeftContent) and moves it to a row beneath its own body, returning it to the same slot when reverted. The toolbar is moved, not rebuilt — its contents survive. The value maps two-way to QuickAccessToolBar.Position; it does not matter which end you set. The lower row is only drawn while BelowRibbon; otherwise it takes no space.
If you put the toolbar in a slot that is filled by a binding (
LeftContent="{Binding …}"), moving it writes a local value into that slot and the binding is lost. Use a fixed slot.
Saving and restoring the layout
NovaRibbon.Persistence.RibbonLayout writes the quick access contents and position, the display mode and the Classic/Simplified choice, tab and group order and visibility, and the commands the user removed, as versioned JSON. No extra package dependency (System.Text.Json).
using NovaRibbon.Persistence;
private static readonly string LayoutPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"MyApp", "ribbon-layout.json");
// On shutdown
Directory.CreateDirectory(Path.GetDirectoryName(LayoutPath)!);
using (var file = File.Create(LayoutPath))
RibbonLayout.Save(MainRibbon, file);
// On startup — after the ribbon template is applied (e.g. in Loaded)
using (var file = File.OpenRead(LayoutPath))
{
RibbonLayoutLoadResult result = RibbonLayout.Load(MainRibbon, file);
if (!result.IsApplied)
Log($"Layout not applied: {result.Status}"); // Empty · Malformed · UnsupportedVersion
else if (result.UnknownCommandIds.Count > 0)
Log($"Commands no longer present: {string.Join(", ", result.UnknownCommandIds)}");
}
Loading never throws. An empty, truncated, corrupt or future-versioned document still returns a result and leaves the ribbon untouched; opening the file and handling IOException belongs to the caller.
RibbonLayoutLoadResult member |
What it tells you |
|---|---|
Status |
Applied · Empty (first run) · Malformed · UnsupportedVersion |
IsApplied |
false means the ribbon was not changed at all |
RestoredCommandIds |
Commands actually put back on the toolbar, in placement order |
UnknownCommandIds |
Commands no longer in the ribbon — removed add-in, renamed id |
RestoredTabIds / UnknownTabIds |
The same for tabs |
RestoredGroupIds / UnknownGroupIds |
The same for groups |
IsTabOrderApplied / IsGroupOrderApplied |
Whether order was really applied; in a bound ribbon the source owns order |
HiddenCommandIds / UnknownHiddenCommandIds |
Which of the user's hidden commands were applied and which are gone |
One missing id does not drop the others: the rest of the user's customization survives. Tabs can only be reordered if they carry ui:RibbonId.Id; tabs without one (and tabs added later) stay where they are. In a bound ribbon the source owns order and only visibility is applied.
To inspect or edit the layout before writing it, RibbonLayout.Capture(ribbon) returns the portable RibbonLayoutData and RibbonLayout.Apply(ribbon, data) does the reverse; applying an empty RibbonLayoutData returns to the default layout.
The user's decisions and the app's are separate. Ribbon.AuthoredLayout stores the layout the application authored, once, when the ribbon loads and before any saved layout is applied. The saved document carries only the deviation from that baseline: a command the app hid never enters the document, and a default the app changes in the next release is not swallowed by the user's old layout.
Your own quick access surface
The ribbon does not know the concrete type; it works through IQuickAccessHost: CommandIds, Contains, Add, Remove, Move, Clear, CommandsChanged. Position and PositionChanged are optional (they have default implementations). Write your own surface and bind it as Ribbon.QuickAccessHost.
For driving the built-in toolbar from code: Add(RibbonCommandInfo), Remove(id), Move(id, index), Contains(id), Clear(), Clear(bool), CommandIds, Commands, CommandsChanged, Position / TogglePosition(), HasOverflowItems, IsOverflowOpen, ItemSpacing, CustomizeMenu, and the statics QuickAccessToolBar.IsProjectable(kind) / GetCommandId(element). A ComboBox or Gallery is a selection surface and cannot be copied into a 26×26 icon; Add rejects them.
Customizing the ribbon
Right-click the ribbon and choose "Customize the Ribbon" to open Word's two-pane dialog. Again there is no application code: the library carries the dialog and enables the menu item when the ribbon enters the tree. To open it from your own options window:
bool? accepted = MainRibbon.ShowCustomizeDialog(this);
| Pane | What it does |
|---|---|
| Left | Every command in the ribbon (including unselected and bound tabs), filtered by a search box. |
| Right | The ribbon tree (tab → group → command, with check boxes) or the quick access list; the combo box at the top chooses. |
| Add / Remove | Moves a command from the left pane to the right target and back. |
| Up / Down | Reorders tabs, groups and quick access commands. |
| Reset | Returns to the layout the application authored, not to an empty ribbon. |
| OK / Cancel | OK applies the layout in one shot; Cancel does nothing. |
Cancel really cancels. The dialog does not work on the live ribbon: a copy of the layout is taken when it opens (RibbonLayout.Capture), every edit happens on that copy, and OK applies it with a single RibbonLayout.Apply. There is no "undo" because nothing was done.
Reset returns to the Ribbon.AuthoredLayout baseline. Applying an empty layout would be wrong: a command the app hid would come back, and tabs the app ordered would fall into declaration order.
An app writing its own dialog has two routes. The easy one is handling CustomizeRequested on the ribbon and setting e.Handled = true; the built-in dialog then never opens. An app handling the event on an ancestor gets its turn after the ribbon's own listener, where Handled is too late; in that case write ShowsBuiltInCustomizeDialog="False".
What the dialog does not do, and why: it does not create custom tabs or groups and does not move a command to another group — all three require giving a control a second logical parent or cloning it, which collides with the registry's duplicate-id check (Office does not let you add to built-in groups either). There is no import/export, because RibbonLayout.Save / Load is already the stream-based equivalent. There is no shortcut editing, because there is no shortcut registry to edit. The quick access pane lists only commands the user added: buttons declared in XAML belong to the application, are not in CommandIds, and are not saved.
MVVM and data binding
The ribbon binds at all three levels: tabs (Ribbon.ItemsSource), a tab's groups (RibbonTab.GroupsSource), and a group's commands (RibbonGroup.ItemsSource). This is the path for apps that host add-ins, apps that build the ribbon per document type or user role, and anyone doing plain MVVM.
A bound ribbon behaves identically to a hand-written one: same metrics, same adaptive shrink stages, same right-click menu, same Ribbon.Commands answers. The working example is the "Add-ins" tab in samples/NovaRibbon.Demo — the "Home" tab beside it is hand-written; they sit side by side and look the same.
Binding groups
The most common shape: the tab stays in XAML, its contents come from data.
<ui:RibbonTab Header="Add-ins" ui:KeyTip.Text="A" ui:RibbonId.Id="Tab.AddIns"
GroupsSource="{Binding AddInGroups}"
GroupContainerStyle="{StaticResource BoundGroupStyle}" />
A group's header, icon, shrink priority, MinVariant and id are container properties, so they are supplied through GroupContainerStyle. The group's contents come from the group container's own ItemsSource:
<Style x:Key="BoundGroupStyle" TargetType="{x:Type ui:RibbonGroup}">
<Setter Property="Padding" Value="6,0" />
<Setter Property="Header" Value="{Binding Title}" />
<Setter Property="Symbol" Value="{Binding Symbol}" />
<Setter Property="ShrinkPriority" Value="{Binding ShrinkPriority}" />
<Setter Property="MinVariant" Value="{Binding MinVariant}" />
<Setter Property="ui:RibbonId.Id" Value="{Binding Id}" />
<Setter Property="ItemsSource" Value="{Binding Commands}" />
<Setter Property="ItemTemplate" Value="{StaticResource BoundCommandTemplate}" />
</Style>
Binding commands
ItemTemplate produces the command's appearance. The default items panel is the horizontal ui:Stack (Spacing="2") that hand-written groups use; supply RibbonGroup.ItemsPanel for vertical rows.
<DataTemplate x:Key="BoundCommandTemplate" DataType="{x:Type vm:RibbonCommandViewModel}">
<ui:RibbonButton Label="{Binding Label}"
Symbol="{Binding Symbol}"
ToolTip="{Binding ToolTip}"
IsEnabled="{Binding IsEnabled}"
Command="{Binding Command}"
CommandParameter="{Binding CommandParameter}"
ui:KeyTip.Text="{Binding KeyTip}"
ui:RibbonId.Id="{Binding Id}" />
</DataTemplate>
The button the template produces inherits the group's Variant: as the window narrows it moves to medium and then icon-only layout exactly like a hand-written button.
For mixed control types in one list, use RibbonGroup.ItemTemplateSelector.
Binding tabs
If the whole ribbon comes from data, the tab container is set up with ItemContainerStyle; because GroupsSource can be supplied from that style, all three levels join into one tree:
<Style x:Key="BoundTabStyle" TargetType="{x:Type ui:RibbonTab}">
<Setter Property="Header" Value="{Binding Title}" />
<Setter Property="ui:KeyTip.Text" Value="{Binding KeyTip}" />
<Setter Property="ui:RibbonId.Id" Value="{Binding Id}" />
<Setter Property="ContextualTabGroup" Value="{Binding ContextualGroup}" />
<Setter Property="GroupsSource" Value="{Binding Groups}" />
<Setter Property="GroupContainerStyle" Value="{StaticResource BoundGroupStyle}" />
</Style>
<ui:Ribbon ItemsSource="{Binding Tabs}" ItemContainerStyle="{StaticResource BoundTabStyle}" />
The container is not a ContentPresenter — and why that matters
An ItemsControl normally wraps every item in a ContentPresenter. NovaRibbon does not: a bound tab is born a real RibbonTab, a bound group a real RibbonGroup.
This is not a style preference. RibbonGroupPanel looks for its children as RibbonGroup when it shrinks; RibbonGroup.Variant is an inherited attached property and the button templates trigger on it. If a group were wrapped in a ContentPresenter, the library's best feature would go silent with no error at all — no exception, no warning, just a ribbon that no longer shrinks. The panel is still defensive: if a consumer's DataTemplate wraps the group, the panel looks a few levels down to find it — but the container is the correct place.
Items inside a group are different: there the items are arbitrary controls, the container is a ContentPresenter, and that is expected — Variant is inherited, so it passes through the presenter.
This has one practical consequence: write Visibility on the container, not on the control the template produces. Layout looks at the panel's children; collapsing the inner control makes the item invisible but does not reclaim its space.
<Style x:Key="BoundCommandContainerStyle" TargetType="{x:Type ContentPresenter}">
<Style.Triggers>
<DataTrigger Binding="{Binding IsVisible}" Value="False">
<Setter Property="Visibility" Value="Collapsed" />
</DataTrigger>
</Style.Triggers>
</Style>
Supply that as the group's ItemContainerStyle.
Which layer holds the id
ui:RibbonId.Id is the key to everything in a bound ribbon too — quick access, persistence and search all use it. The layering rule is simple:
| What | Where | Why |
|---|---|---|
| Tab id | Ribbon.ItemContainerStyle |
The container is a real RibbonTab. |
| Group id | RibbonTab.GroupContainerStyle |
The container is a real RibbonGroup. |
| Command id | on the control inside ItemTemplate |
The container is a ContentPresenter. Put the id there and FindCommand can only produce Kind = Other, so quick access gets a button with no icon and no label. On the real command control the definition becomes Kind = Button and the button draws correctly on every surface. |
Collections that change at runtime
If the source implements INotifyCollectionChanged (ObservableCollection<T>), adding and removing tabs, groups and commands is reflected in the ribbon and the registry immediately. Loading or unloading an add-in needs nothing else.
Ribbon.Commands does not care how the ribbon was written
In a bound ribbon the containers come from ItemContainerGenerator, which normally only runs when the panel is measured: the containers of an unselected tab may never be born. Having the same Find call answer differently depending on how the ribbon was authored was not acceptable, so the registry generates the containers itself while scanning. Generation does not touch the visual tree — the unselected tab stays unrealized and its template is not applied — only the objects are created, which is the cost a hand-written ribbon already pays at XAML parse time. After the first miss, lookups come from the dictionary.
// Fully bound ribbon, tab 1 selected: a command on tab 3 is still found.
RibbonCommandInfo? info = MainRibbon.Commands.FindCommand("AddIns.Tools.Pin");
Layout persistence in a bound ribbon
RibbonLayout works in a bound ribbon too, with one difference: tab order belongs to the source and the layout does not interfere. Visibility is still applied. Which happened is not left to guesswork:
var result = RibbonLayout.Load(MainRibbon, stream);
if (result.IsApplied && !result.IsTabOrderApplied)
{
// Tab order is the view model's job: apply the saved order there.
}
Right-to-left
Set FlowDirection="RightToLeft" on your window and the ribbon mirrors. There is no NovaRibbon-specific switch to find and nothing to configure per control:
<ui:NovaWindow FlowDirection="RightToLeft" ...>
Tabs, groups, the quick access toolbar, the Simplified row and its overflow menu, the status bar and the Backstage rail all lay out right-to-left. Tab-strip scrolling steps in the reading direction, and the chevrons point and disable at the correct ends. Ctrl+Left / Ctrl+Right in the quick access toolbar move a button in the direction it visually points, not the direction the key is named.
Directional glyphs (chevrons, undo/redo) mirror. Object icons (Save, Copy, Table) do not — a picture of a floppy disk is not a direction.
Accessibility
The measure is not "are there peers" but can a screen reader user drive the ribbon. Every command reports its name, control type, state and keyboard shortcut; the structure is navigable; the patterns really drive the control.
The automation tree
Ribbon (Tab, "Ribbon")
└── RibbonTab (TabItem, "Home", 1 of 9)
└── RibbonGroup (Group, "Font")
├── RibbonButton (Button, "Bold") ........... Invoke
├── RibbonToggleButton (Button, "Italic") ... Toggle + Invoke
└── RibbonFontFamilyComboBox (ComboBox) ..... ExpandCollapse + Value
The group layer matters: without it a tab is forty buttons glued together and the user cannot navigate by "Clipboard", "Font", "Paragraph". Tabs report their position in the set ("2 of 9") and hidden contextual tabs do not inflate the count.
| Control | Type | Patterns |
|---|---|---|
Ribbon |
Tab |
Selection |
RibbonTab |
TabItem |
SelectionItem (+ position) |
RibbonGroup |
Group |
ExpandCollapse (only while collapsed) |
RibbonButton |
Button |
Invoke |
RibbonToggleButton |
Button |
Toggle, Invoke |
RibbonSplitButton |
SplitButton |
Invoke and ExpandCollapse |
RibbonMenuButton |
Button |
ExpandCollapse (+ Invoke, opens the panel) |
RibbonColorButton |
Button |
Invoke, ExpandCollapse |
RibbonCheckBox / RibbonRadioButton |
CheckBox / RadioButton |
Toggle / SelectionItem |
RibbonComboBox and the font boxes |
ComboBox |
ExpandCollapse, Value (when editable) |
RibbonTextBox |
Edit |
Value, Text |
RibbonSpinner |
Spinner |
RangeValue, Value |
RibbonGallery / RibbonGalleryItem |
List / ListItem |
Selection / SelectionItem + ScrollItem |
RibbonMenuItem |
MenuItem |
Invoke / Toggle / ExpandCollapse by role |
QuickAccessToolBar / QuickAccessButton |
ToolBar / Button |
Invoke |
SearchBox |
Edit |
Value, ExpandCollapse |
Backstage / BackstageItem |
Tab / TabItem |
SelectionItem |
A pattern that cannot be driven is not promised: RibbonGroup offers ExpandCollapse only while Variant == Collapsed, because in a wide layout there is no panel to open. RibbonSplitButton offers both, because it really does two things.
Name precedence
One place decides the name (NovaRibbon.Automation.RibbonAutomationNames) and the first non-empty wins:
AutomationProperties.Name— the consumer's name. Wins unconditionally.Label— ribbon buttons draw their text fromLabel, notContent.- Content text —
Content/Header(check box, radio button, menu row). ScreenTip.Titleor a plain-text tooltip — the only name an icon-only command has.- For content controls with no visible text (combo box, text box, spinner, gallery), the localized control type as a last resort.
HelpText comes from the ScreenTip description, AcceleratorKey from ScreenTip.Shortcut and AccessKey from ui:KeyTip.Text — which is what makes Word's "Bold, button, off, Ctrl+B" reading possible. A tooltip title identical to the name is not repeated in the help text.
Overriding a name is one line:
<ui:RibbonButton Label="B"
AutomationProperties.Name="Bold text"
ui:KeyTip.Text="1">
<ui:RibbonButton.ToolTip>
<ui:ScreenTip Title="Bold" Description="Makes the selected text bold." Shortcut="Ctrl+B" />
</ui:RibbonButton.ToolTip>
</ui:RibbonButton>
Keyboard
Tab walks the ribbon and every stop draws Fluent's two-tone focus ring (2px outer + 1px inner). The ring only appears for keyboard focus — WPF's own KeyboardNavigation logic decides that, there is no separate tracker. The ring is drawn inside the element's rectangle, because the ribbon card and the group panels clip their content and an outward ring would be eaten. The corner radius is chosen by style: NovaFocusVisual (3px), NovaFocusVisualSquare, NovaFocusVisualRounded (4px), NovaFocusVisualPill, NovaFocusVisualCircle.
For the three-level Alt badges see KeyTips.
High contrast
If the user told the operating system which colours they want, the application does not get to pick its own. Themes/HighContrast.xaml therefore writes no colour at all: every key binds to a SystemColors resource key through DynamicResource, so moving from one contrast scheme to another brings the colours along.
Three points were settled by measurement rather than taste:
- The highlight fill is used only where everything on top of it turns
HighlightText.WindowTexton a Highlight fill measures 1.44:1 – 1.91:1 across the four Windows schemes — it is not readable. On a pressed button the label and the icon turn together. - The carve-out layer (
Invert) maps to the SURFACE, not the foreground. Mapped to the foreground, the solid body and the carve-out would land on the same colour and a two-tone icon would collapse into one block. - Hover is an outline, not a fill. Under high contrast the hover fill and the surface are the same system colour, so a fill swap is invisible. Every interactive surface draws
NovaStateOutlineBrushatNovaStateOutlineThicknessinstead. Both tokens are transparent and zero-width in Light and Dark, so those palettes render identically.
Limits
LocalizedControlTypeis never overridden: Narrator says "button", not "toggle button". UIA has no separate type; the role is carried by theTogglepattern.- The dialog launcher is
Focusable="False"and therefore not reachable withTab; Word reaches it with a KeyTip too. Adding it to the tab stops would change the ribbon's tab order. - Peers are proven through
AutomationPeer/ provider interfaces; a full pass with an out-of-process UIA client (Narrator, Accessibility Insights) should be run separately before any VPAT claim. - A submenu inside a third-level KeyTip panel is not entered; that would need a fourth level.
Control list
| Area | Controls |
|---|---|
| Window | NovaWindow, TitleBar, TitleBarButton, SearchBox, QuickAccessToolBar, QuickAccessButton, NovaStatusBar, ZoomControl, ViewModeSelector, ViewModeItem |
| Ribbon | Ribbon, RibbonTab, RibbonGroup, RibbonButton, RibbonToggleButton, RibbonSplitButton, RibbonColorButton, RibbonMenuButton, RibbonMenuItem, RibbonCheckBox, RibbonRadioButton, RibbonSeparator, RibbonGallery, RibbonGalleryItem, RibbonContextualTabGroup |
| Ribbon inputs | RibbonComboBox (editable), RibbonFontFamilyComboBox, RibbonFontSizeComboBox, RibbonTextBox, RibbonSpinner, RibbonLabel |
| Backstage | Backstage, BackstageItem, BackstageCommandItem, BackstageSeparatorItem, BackstageHeaderItem |
| Customization | RibbonCustomizeDialog, RibbonCustomizationNode |
| Floating | MiniToolBar, MiniToolBarService |
| Content | Card, NovaButton, NovaTextBox, ToggleSwitch, InfoBar, NumberBox, ColorGallery, ScreenTip |
| Layout | Stack (spaced stack), SpacedTextBlock (letter spacing) |
| Graphics | NovaIcon, SymbolIcon, SymbolCatalog |
Implicit styles also ship for Button, TextBox, PasswordBox, ComboBox, CheckBox, RadioButton, Slider, ProgressBar, ListBox, TreeView, DataGrid, Menu, ToolBar, Expander, GroupBox, ContextMenu, MenuItem, ToolTip and ScrollBar.
Icons
The built-in catalog comes from Microsoft's Fluent UI System Icons (MIT licensed). The geometry is embedded into C# source at build time — there is no network access at runtime and no font to install.
<ui:SymbolIcon Symbol="Paste" IconSize="32" />
<ui:SymbolIcon SymbolKey="my-icon" IconSize="16" />
SymbolKey is available directly on command buttons (RibbonButton, RibbonToggleButton, RibbonSplitButton, QuickAccessButton) and is how you go outside the built-in Symbol set. When set it wins over Symbol; if the key is not in the catalog the view falls back to Symbol silently.
Layer roles
An icon is an ordered list of layers, and a layer's colour is not given directly — it is resolved from the theme through a role. One definition is therefore correct in both light and dark, and icons repaint instantly on a theme switch.
| Role | Token | Used for |
|---|---|---|
Neutral |
NovaIconNeutralBrush |
the body of ribbon command icons |
Secondary |
NovaIconSecondaryBrush |
the secondary tone |
Accent |
NovaIconAccentBrush |
meaningful accent (table, picture, link…) |
Invert |
NovaIconInvertBrush |
the carve-out inside a solid shape |
Warm |
NovaIconWarmBrush |
warm accent |
Foreground |
(the host control's Foreground) |
chrome glyphs: window buttons, chevrons, check marks |
The Foreground role follows the colour of the button the icon sits in — that is how the close button's "X" turns white on hover.
Adding or replacing an icon
The catalog is not closed. Register your own at runtime:
SymbolCatalog.Register("my-icon", new SymbolDefinition(
20, 20,
new SymbolLayer("M3 4h14v12H3z", SymbolLayerRole.Neutral),
new SymbolLayer("M6 7h8v2H6z", SymbolLayerRole.Accent)));
SymbolCatalog.Register(Symbol.Save, myOwnSaveIcon); // override a built-in
To add one permanently, add the member to the Symbol enum, add a line to tools/icons/icon-map.json, and run the importer:
python tools/icons/import-fluent.py # fetches and regenerates SymbolCatalog.Fluent.cs
python tools/icons/import-fluent.py --check # is the generated file current (for CI)
Pick icon names at https://icon-sets.iconify.design/fluent and use the 20 size variants (the library's source grid is 20×20).
Verify the licence before pulling in another Iconify set — they are not all MIT. Details and attribution: THIRD-PARTY-NOTICES.md.
The size on screen is always the host control's IconSize (QuickAccessButton.IconSize, RibbonButton.IconSize, SymbolIcon.IconWidth/IconHeight). The ladder matches Word: large button 32, small/medium 16, quick access 16.
Project layout
NovaRibbon.slnx
src/NovaRibbon/ the library (net10.0-windows)
Appearance/ ThemeManager, SystemThemeWatcher
Automation/ UI Automation peers (name, type, patterns)
Controls/ controls
Controls/Ribbon/ the ribbon family
Customization/ the customize dialog and its model
Localization/ Strings.resx (+ tr) and the Localize markup extension
Persistence/ RibbonLayout (save / restore)
Media/ the icon model (SymbolLayer, SymbolDefinition)
Media/SymbolCatalog.Fluent.cs generated icon geometry (do not edit)
Themes/Light.xaml light palette
Themes/Dark.xaml dark palette (same keys)
Themes/HighContrast.xaml high contrast, bound to SystemColors
Themes/Nova.xaml all control styles
Themes/Generic.xaml fallback for type-keyed styles (see docs/theming.md)
samples/NovaRibbon.Demo/ demo application
tests/NovaRibbon.Tests/ the test suite
tools/icons/ icon importer (icon-map.json + import-fluent.py)
Building from source
git clone https://github.com/shadesofdeath/NovaRibbon.git
cd NovaRibbon
dotnet build NovaRibbon.slnx
dotnet test tests/NovaRibbon.Tests
dotnet run --project samples/NovaRibbon.Demo
The demo is the reference application: hand-written and bound tabs side by side, Backstage, contextual tabs, galleries, the customize dialog, quick access persistence, and light / dark / high-contrast switching.
Startup arguments useful for scripted screenshots: --theme=dark, --lang=en, --layout=simplified.
Contributing
Issues and pull requests are welcome. Two house rules the CI enforces:
- Zero warnings. CI builds with
TreatWarningsAsErrorsandCS1591is live — every public member needs an XML doc comment. - Tests stay green. A WPF test that opens a popup must close it before the window closes, or the run count becomes unstable.
License
MIT. Icon geometry derives from Fluent UI System Icons, also MIT; see THIRD-PARTY-NOTICES.md.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0-windows7.0 is compatible. |
-
net10.0-windows7.0
- No dependencies.
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.0.0-preview.2 | 78 | 8/22/2026 |
| 1.0.0-preview.1 | 63 | 8/22/2026 |