NovaRibbon 1.0.0-preview.2

This is a prerelease version of NovaRibbon.
dotnet add package NovaRibbon --version 1.0.0-preview.2
                    
NuGet\Install-Package NovaRibbon -Version 1.0.0-preview.2
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="NovaRibbon" Version="1.0.0-preview.2" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="NovaRibbon" Version="1.0.0-preview.2" />
                    
Directory.Packages.props
<PackageReference Include="NovaRibbon" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add NovaRibbon --version 1.0.0-preview.2
                    
#r "nuget: NovaRibbon, 1.0.0-preview.2"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package NovaRibbon@1.0.0-preview.2
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=NovaRibbon&version=1.0.0-preview.2&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=NovaRibbon&version=1.0.0-preview.2&prerelease
                    
Install as a Cake Tool

<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.

NuGet CI License: MIT .NET 10

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 dotnet CLI

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.xaml exists but carries only the styles, not the palette — the palette keys are plain strings rather than ComponentResourceKey, so WPF's implicit theme lookup cannot resolve them. Merging Light.xaml (or Dark.xaml) explicitly is the supported path. See docs/theming.md.


Table of contents


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.

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 (default 40) rather than RibbonBodyHeight. 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 Variant your app set is never touched. The stage actually on screen is reported by the read-only RibbonGroup.EffectiveVariant (always Small in 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.EffectiveContent reports 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.IsOverflowing and RibbonGroupPanel.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.Commands all 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.

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 / PreviewCanceled bubble 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:

  1. AutomationProperties.Name — the consumer's name. Wins unconditionally.
  2. Label — ribbon buttons draw their text from Label, not Content.
  3. Content text — Content / Header (check box, radio button, menu row).
  4. ScreenTip.Title or a plain-text tooltip — the only name an icon-only command has.
  5. 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. WindowText on 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 NovaStateOutlineBrush at NovaStateOutlineThickness instead. Both tokens are transparent and zero-width in Light and Dark, so those palettes render identically.

Limits

  • LocalizedControlType is never overridden: Narrator says "button", not "toggle button". UIA has no separate type; the role is carried by the Toggle pattern.
  • The dialog launcher is Focusable="False" and therefore not reachable with Tab; 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 TreatWarningsAsErrors and CS1591 is 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 Compatible and additional computed target framework versions.
.NET net10.0-windows7.0 is compatible. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • 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