Skip to content
nicoseijasPublic

About

A UI library for WPF applications that need to look current without adopting Fluent or Material.

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Celeste

NuGet CI .NET License

Celeste is a UI library for WPF applications that need to look current without adopting the Fluent or Material design language. It restyles the controls WPF already ships, adds the few it never did, and drives everything from one set of semantic color tokens that can be swapped at runtime.

The gallery's Display tab, cut down the middle: the light theme on the left, the dark theme on the right, showing badges, progress bars, card elevation, and a card with a footer

Both halves are the same screen. Every picture here comes out of the sample — ./eng/capture-screenshots.ps1 regenerates them.

What it gives you

  • Light and dark themes built from the same token names, switchable while the app runs. ThemeManager.Apply(ApplicationTheme.System) follows the Windows app theme and keeps following it.
  • Restyled built-in controls: Button, ToggleButton, TextBox, PasswordBox, CheckBox, RadioButton, ComboBox, ListBox, TabControl, Slider, ProgressBar, ScrollBar, ToolTip, Label, Separator.
  • The controls that looked like 2006: the DataGrid in the box, restyled — sunken header, ruled rows, a sort glyph, and templated columns for the cells that hold an avatar or a badge.
  • Controls WPF does not have: Card, Badge, ToggleSwitch, ProgressRing, Avatar, a Stack that spaces its children the way a CSS gap does, an ImageView that loads and decodes pictures off the UI thread, a MasonryPanel that fills the shortest column, and a Sidebar that collapses to an icon rail or slides in over the content.
  • Button variants — Celeste.Button.Primary, .Secondary, .Destructive, .Outline, .Ghost, .Link — that all share one ControlTemplate. Defining a new variant means setting three brushes, not copying a template.
  • A type scale and layout tokens (spacing, radii, control heights) exposed as XAML resources, so your own controls can sit on the same grid as the library's.

Installation

The package is Celeste.Wpf on nuget.org. Every release so far is a prerelease, and NuGet hides those unless you ask for them:

dotnet add package Celeste.Wpf --prerelease

Pin the exact version instead once you depend on it — 0.x releases may break the API, which is what the version number is telling you.

Then merge the two dictionaries into App.xaml. Order matters only for readability — control styles reference color tokens through DynamicResource.

<Application x:Class="MyApp.App"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:celeste="https://celeste-ui.dev/wpf"
             StartupUri="MainWindow.xaml">
    <Application.Resources>
        <ResourceDictionary>
            <ResourceDictionary.MergedDictionaries>
                <celeste:ThemesDictionary Theme="System" />
                <celeste:ControlsDictionary />
            </ResourceDictionary.MergedDictionaries>
        </ResourceDictionary>
    </Application.Resources>
</Application>

Apply Celeste.Window to your window. Every control below it inherits the theme's font, foreground, and background:

<Window x:Class="MyApp.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:celeste="https://celeste-ui.dev/wpf"
        Style="{StaticResource Celeste.Window}"
        Title="My app" Width="600" Height="400">

    <celeste:Card Header="Workspace" Description="Everyone with the link can view this."
                  Margin="24">
        <StackPanel>
            <TextBox celeste:ControlHelper.PlaceholderText="Workspace name" />
            <celeste:ToggleSwitch Content="Public" Margin="0,12,0,0" />
        </StackPanel>
        <celeste:Card.Footer>
            <Button Content="Save" Style="{StaticResource Celeste.Button.Primary}"
                    HorizontalAlignment="Right" />
        </celeste:Card.Footer>
    </celeste:Card>
</Window>

Switching themes at runtime

using Celeste.Wpf.Theming;

ThemeManager.Apply(ApplicationTheme.Dark);    // pin dark
ThemeManager.Apply(ApplicationTheme.System);  // follow Windows, and keep following it

ThemeManager rewrites the ThemesDictionary you merged in App.xaml. Controls repaint immediately because every built-in style references color tokens through DynamicResource. If your own XAML uses StaticResource for a Celeste brush, it will keep the color it was given at load time.

Buttons

A plain <Button> is the secondary variant. The other five are keyed styles, so a button asks for its variant by name:

<Button Content="Save" Style="{StaticResource Celeste.Button.Primary}" />
<Button Content="Delete" Style="{StaticResource Celeste.Button.Destructive}" />

Celeste.Button.Small and Celeste.Button.Large change the metrics — font size, height, padding — and nothing else. WPF styles do not compose, so a large primary button is a style of your own: BasedOn the variant, then repeat those three setters.

A button holding an icon and no label is square, and asks for that with a property rather than a style — a style could only be one variant, and a toolbar button is still ghost, primary, or destructive:

<Button celeste:ControlHelper.IsIconOnly="True"
        AutomationProperties.Name="Notifications"
        Style="{StaticResource Celeste.Button.Ghost}">
    <Path Data="…" Stroke="{DynamicResource Celeste.Brush.Foreground}" />
</Button>

The width follows the height the button already has, so a small icon button stays square instead of turning oblong. Name it: a button whose label is gone has nothing left to announce.

For a row of buttons that is a single choice, put Celeste.ToggleButton.Segment options inside a Celeste.Segmented track:

<ItemsControl Style="{StaticResource Celeste.Segmented}">
    <RadioButton Content="Day" GroupName="Range" IsChecked="True" Style="{StaticResource Celeste.ToggleButton.Segment}" />
    <RadioButton Content="Week" GroupName="Range" Style="{StaticResource Celeste.ToggleButton.Segment}" />
</ItemsControl>

A segment is a RadioButton, so one option is checked per GroupName and clicking the checked one keeps it checked instead of leaving the choice empty. The track is what makes the row legible: a segment draws no border and no fill until it is the chosen one, so on a plain surface the checked segment is a panel the colour of the panel under it. A ToggleButton is styled too, for a choice that stands on its own.

Text

Celeste defines no implicit TextBlock style — one would override the font a Button passes down to its own content. Font and foreground come from Celeste.Window by inheritance, and the scale is a set of keyed styles:

Style What it is for
Celeste.TextBlock.Body The base. Sets text rendering only, so it looks like inherited text.
Celeste.TextBlock.Muted Secondary text: the same size, the muted foreground.
Celeste.TextBlock.Caption Smaller and muted. Labels under a value, timestamps.
Celeste.TextBlock.Strong Body weight raised to SemiBold.
Celeste.TextBlock.Subtitle A heading inside a section.
Celeste.TextBlock.Title A page or dialog heading, in the display face.
Celeste.TextBlock.Display The largest step, for a number or a single line that is the point of the screen.
Celeste.TextBlock.Code Monospace, with Display formatting so stems stay on the pixel grid.
<TextBlock Text="Workspace" Style="{StaticResource Celeste.TextBlock.Title}" />
<TextBlock Text="Everyone with the link can view this." Style="{StaticResource Celeste.TextBlock.Muted}" />

Label and Separator are styled implicitly and need no key. A Separator is a one-pixel rule in the border colour with no margin of its own; Celeste.Separator.Vertical turns it on its side, for the rules between the figures in a stat strip.

Navigation

A sidebar with a workspace group holding Overview, Reports with a badge of 12, and Members, an account group holding Billing, a disabled Archived item, and Settings pinned to the bottom

Sidebar is a ListBox, so selection is an ordinary binding and Celeste never owns your navigation state. Setting IsCollapsed switches it to an icon rail; the binding runs both ways, so the built-in header chevron and your own layout rules can drive the same property.

<celeste:Sidebar Header="Acme" SelectedItem="{Binding CurrentPage}" IsCollapsed="{Binding IsRail}">
    <celeste:Sidebar.CollapsedHeader>
        <Path Data="M10,0 L20,10 L10,20 L0,10 Z" Fill="{DynamicResource Celeste.Brush.Primary}" />
    </celeste:Sidebar.CollapsedHeader>

    <celeste:SidebarGroupHeader Content="WORKSPACE" />
    <celeste:SidebarItem Content="Overview">
        <celeste:SidebarItem.Icon>
            <Path Data="M1,1 H7 V7 H1 Z M11,1 H17 V7 H11 Z M1,11 H7 V17 H1 Z M11,11 H17 V17 H11 Z"
                  Style="{StaticResource Celeste.SidebarItem.Icon}" />
        </celeste:SidebarItem.Icon>
    </celeste:SidebarItem>
    <celeste:SidebarItem Content="Reports" Badge="12" />
    <celeste:SidebarSeparator />
    <celeste:SidebarItem Content="Archived" IsEnabled="False" />

    <celeste:Sidebar.Footer>
        <celeste:SidebarItem Content="Settings" />
    </celeste:Sidebar.Footer>
</celeste:Sidebar>

Give every item an Icon if the sidebar can collapse: the rail hides labels and moves them into tooltips, so an item without an icon becomes an empty row. Celeste ships no icon set — Celeste.SidebarItem.Icon is a style that only makes your own Path follow the item's hover, selected, and disabled colors.

Header is hidden in the rail too, and CollapsedHeader is what replaces it — a logo mark rather than a name, since the rail is only CollapsedWidth wide. It does not fall back to Header: a wordmark scaled into 56 pixels is unreadable. If you supply neither a mark nor the toggle button, the rail drops the header band instead of leaving it blank.

Arrow keys move between items and step over SidebarGroupHeader and SidebarSeparator. The sidebar sets its own Width from ExpandedWidth and CollapsedWidth, so put it in an Auto grid column rather than sizing it yourself.

Off-canvas and responsive

SidebarHost decides how the pane is presented instead of leaving it to your layout. Auto reads the host's own width: wide is docked, narrower is the rail, narrow takes the pane out of the layout and slides it in over the content on demand.

<celeste:SidebarHost x:Name="Shell">
    <celeste:SidebarHost.Pane>
        <celeste:Sidebar Header="Acme" SelectedItem="{Binding CurrentPage}">
            <celeste:SidebarItem Content="Overview" />
        </celeste:Sidebar>
    </celeste:SidebarHost.Pane>

    <DockPanel>
        <Button DockPanel.Dock="Top"
                Command="{x:Static celeste:SidebarHost.TogglePaneCommand}"
                Content="Menu" />
        <ContentControl Content="{Binding CurrentPage}" />
    </DockPanel>
</celeste:SidebarHost>

Celeste never opens the pane on its own — it cannot know where your menu button belongs. Bind one to TogglePaneCommand, which routes, so the button has to sit inside the host. The command only executes while the pane is off-canvas, so the button disables itself once the pane is docked; bind Visibility to ActualDisplayMode if you would rather it disappear.

While the overlay is open, a scrim covers the content, Tab stays inside the pane, and Esc or a click on the scrim closes it and returns focus. Set DisplayMode to Docked, Rail, or OffCanvas to drive the presentation yourself; the breakpoints are then ignored. ActualDisplayMode always reports what you are actually looking at.

Pages and history

NavigationHost holds the page and remembers where you have been. It does not navigate: it watches its own Content, so binding that to a selection is the whole integration and Celeste never becomes your router.

<celeste:NavigationHost x:Name="Pages"
                        Content="{Binding SelectedItem, ElementName=Nav, Mode=TwoWay}"
                        ContentTemplate="{StaticResource PageTemplate}" />

<Button Command="NavigationCommands.BrowseBack" CommandTarget="{Binding ElementName=Pages}" Content="Back" />

Bind Content two ways when the pages come from a selection. Going back writes the previous page into Content, and a one-way binding would leave the sidebar highlighting a page the host is no longer showing.

Back is NavigationCommands.BrowseBack, not a command of Celeste's own, so it arrives with the gestures Windows already assigns. It routes, so put the button inside the host or point it at one with CommandTarget, as above. CanGoBack and BackStackDepth are bindable; GoBack() and ClearHistory() are there for code.

Each page is put back where you left it. Going forward is a fresh look and starts at the top — only Back restores a position. The stack is capped by MaxBackStackDepth (10 by default), because an unbounded stack is an unbounded reference to every page the application has ever shown. If your pages scroll themselves, set IsScrollEnabled="False" so they are not nested inside a second scrolling region; positions are then no longer restored, since the host is no longer what scrolls.

There is no forward stack and no transition between pages.

Menus

Menu, MenuItem and ContextMenu are styled implicitly, so a menu written the ordinary way arrives themed:

<Menu>
    <MenuItem Header="_File">
        <MenuItem Header="Open…" InputGestureText="Ctrl+O" />
        <Separator />
        <MenuItem Header="Recent">
            <MenuItem Header="analytics-service" />
        </MenuItem>
    </MenuItem>
</Menu>

An item shows its Icon in a fixed column so labels line up whether or not there is one, a checkable item shows a check in that same column, InputGestureText sits muted on the right, and an item with children gets an arrow. A submenu opens into a rounded panel with a border and a shadow, and the popup animation is the one Windows asks for, so it stops when the user turns animations off.

Separators inside a menu are inset rather than edge to edge. That style is keyed by MenuItem.SeparatorStyleKey rather than declared implicitly, because a menu assigns it to its own separators and that assignment beats any implicit style.

A MenuItem has four appearances and picks one from its Role, which comes from where it sits: on the bar or inside a submenu, with children or without. Nothing in the markup sets it.

Tables

Celeste restyles the DataGrid WPF ships rather than writing one. Sorting, filtering, pinned columns, row actions and paging stay where they were, with the application; what changes is that the grid stops clashing with everything around it.

<DataGrid AutoGenerateColumns="False"
          CanUserAddRows="False"
          IsReadOnly="True"
          ItemsSource="{Binding Tasks}">
    <DataGrid.Columns>
        <DataGridTextColumn Width="*" Header="Task" Binding="{Binding Name}" />
        <DataGridTextColumn Width="170" Header="Assignee" Binding="{Binding Assignee}" />
    </DataGrid.Columns>
</DataGrid>

The header row is sunken and muted, each row rules itself off with a border along its top instead of the grid drawing a mesh of lines, and the sort direction is a glyph as well as a position, so it does not rest on colour alone. The rule goes above a row rather than below it so that the last one does not land on the grid’s own bottom border, where the two would read as a single thick line running into the rounded corner. Row headers are off and row-height dragging is off; both are ordinary properties and you can turn them back on.

Selection is painted by the cell, which is what makes both selection units read correctly: SelectionUnit="FullRow" fills the whole row because every cell in it is selected, and "Cell" fills only the one that is. The keyboard-focused cell draws its ring over either.

A column holding something other than text is a DataGridTemplateColumn and stays yours — an avatar, a Badge, a row of actions. A templated cell announces less to a screen reader than a text one, so name the row when you use them:

<DataGrid.RowStyle>
    <Style BasedOn="{StaticResource Celeste.DataGridRow}" TargetType="{x:Type DataGridRow}">
        <Setter Property="AutomationProperties.Name" Value="{Binding Summary}" />
    </Style>
</DataGrid.RowStyle>

Two things worth knowing. SelectedIndex set as an attribute is applied while the grid still has no columns and does not stick; set it after the <DataGrid.Columns> block, or from code. And an editable column supplies its own editing element, which Celeste does not style — an editable grid puts a Windows text box inside the cell.

Layout

Stack is a StackPanel with a gap: the panel owns the separation between its children, so none of them carries a margin and the last one is followed by nothing.

<celeste:Stack Spacing="{DynamicResource Celeste.Space.Lg}">
    <celeste:Card Header="One">…</celeste:Card>
    <celeste:Card Header="Two">…</celeste:Card>
</celeste:Stack>

Orientation behaves as it does on StackPanel and defaults to Vertical. Spacing defaults to 12, the value of Celeste.Space.Md; set it to 0 for the flush stacking WPF's own panel gives you.

By default each child keeps the size it asks for and anything left over stays empty. Two properties change that, and between them they cover what grid-template-columns is usually reached for.

IsUniform divides the axis into equal shares, so a row of cards fills the width and the last one ends where the panel does:

<celeste:Stack Orientation="Horizontal" Spacing="12" IsUniform="True">
    <celeste:Card Header="One" />
    <celeste:Card Header="Two" />
    <celeste:Card Header="Three" />
</celeste:Stack>

Stack.Star is the same idea as a star column in a Grid: children that keep their own size are measured first, and what is left is divided between the star children in proportion to their weights. A uniform stack ignores it.

<celeste:Stack Orientation="Horizontal" Spacing="12">
    <celeste:Card Width="140" Header="Fixed" />
    <celeste:Card celeste:Stack.Star="2" Header="Detail" />
    <celeste:Card celeste:Stack.Star="1" Header="Aside" />
</celeste:Stack>

Dividing needs a bounded axis. Inside a ScrollViewer that scrolls the stacking direction there is nothing to divide, so a star child keeps its own size and a uniform stack follows its widest child.

The other axis belongs to the child. It is offered the whole panel, so a horizontal stack makes its children as tall as itself unless they say otherwise — VerticalAlignment="Top" or Center on the child keeps it at its own height, exactly as it does in a StackPanel.

A collapsed child takes no space and leaves no gap behind it, which is the part margins cannot do: binding Visibility on one row of a stack does not leave a hole where it was, and in a uniform stack the others take over its share.

It is a Panel rather than a StackPanel subclass. StackPanel implements IScrollInfo in terms of its own layout, and a subclass that moves children without also correcting that arithmetic scrolls to the wrong offset — so inside a ScrollViewer a Stack scrolls by pixel rather than by item. Like StackPanel, and unlike VirtualizingStackPanel, it does not virtualize.

Pictures

ImageView takes a URI and does the rest: the file is read and decoded on a background thread, at the width the control was laid out at rather than at full resolution, and the result is shared with every other view showing the same picture at the same size.

<celeste:ImageView Source="https://example.com/cover.jpg" AspectRatio="1.5" />

It takes the width its layout gives it and derives its height from the aspect ratio — AspectRatio when you set one, the picture's own once it is known, and a square until then. That is what lets it sit in a masonry column, where the width is the column and only the picture knows the height. For a picture that should be its own natural size, set Width and Height, or use a plain Image.

Setting AspectRatio removes the reflow that happens when the real ratio arrives, so it is worth setting whenever you know the shape in advance. Stretch defaults to UniformToFill, which crops rather than distorts.

State reports None, Loading, Loaded, or Failed, and is bindable. A failure also raises ImageFailed with the exception — the library never writes to a log of yours, so that event is the only place the reason exists. Placeholder is whatever should occupy the tile until the picture is there.

http, https, file, and pack URIs work, and a relative URI resolves against the element's base URI the way it does on Image — so Assets/cover.png behaves as you would expect. A resource compiled into an assembly is pack://application:,,,/Assembly;component/path.

Remote pictures go through Celeste.Wpf.Media.ImageLoader, whose HttpClient you can replace at startup with one that carries your handler, proxy, or authentication. Two limits are applied before any memory is committed to pixels: MaxSourceBytes (32 MiB) bounds the file, and MaxDecodedPixels (64 million, about 244 MiB of pixel data) bounds what the file expands into. They are different numbers for a reason — a few hundred kilobytes of compressed data can describe an image tens of thousands of pixels on a side.

Avatars

<celeste:Avatar Source="{Binding PhotoUri}" Initials="NS" Size="Large" />

The initials are the fallback for every case where there is no picture: none set, still loading, or a URI that failed. Celeste will not derive them from a name — which letters stand for a person depends on their language and their name order, and a two-letter rule that reads well in one script mangles another. Size is Small, Medium, or Large; setting Width and Height overrides all three.

Masonry

MasonryPanel puts each child into whichever column is shortest at the time, so tiles of unequal height leave no ragged gap.

<celeste:MasonryPanel MinColumnWidth="220" ColumnSpacing="12" RowSpacing="12">
    <celeste:ImageView Source="one.jpg" />
    <celeste:ImageView Source="two.jpg" />
    <celeste:Card Header="Any content">…</celeste:Card>
</celeste:MasonryPanel>

The column count comes from the panel's own width unless you set Columns: it fits as many MinColumnWidth columns as it can and shares what is left between them, so a resize reflows. Children are measured with an unconstrained height, so each has to decide its own — anything that stretches to fill instead measures to nothing.

Two things to know before using it. Shortest-column placement changes reading order: the third tile can end up beside the first rather than under it, which is the point of the layout and the reason not to use it for content that has to be read in sequence. And nothing here virtualizes: every child is measured and arranged on every pass, which is right for the tens of tiles a gallery view shows and wrong for thousands.

Theming it

Colors are semantic, not literal. Celeste.Brush.Primary is "the brand action color", and the light and dark dictionaries define it differently. To recolor the library, override the tokens after merging Celeste:

<ResourceDictionary.MergedDictionaries>
    <celeste:ThemesDictionary Theme="System" />
    <celeste:ControlsDictionary />
</ResourceDictionary.MergedDictionaries>

<SolidColorBrush x:Key="Celeste.Brush.Primary" Color="#FF7C3AED" />
<SolidColorBrush x:Key="Celeste.Brush.PrimaryHover" Color="#FF6D28D9" />

The full token list lives in src/Celeste.Wpf/Themes/Light.xaml. Every key defined there also exists in Dark.xaml; a test enforces that, because a key present in only one theme becomes a missing brush the moment a user toggles.

For per-control tweaks without retemplating, Celeste.Wpf.Controls.ControlHelper exposes attached properties: CornerRadius, PlaceholderText, IconContent, and IsIconOnly.

Rounding something into a pill

A pill is half the element's height at every corner, and no more. WPF does not clamp a corner radius to what the shape can hold — it overlaps the arcs — so the CSS habit of asking for a huge radius draws a lens with pointed ends on anything wider than it is tall. Celeste.Radius.Pill is 999 and is for square elements only, where that overlap happens to describe a circle.

Halve the height in the markup when it is a constant. When the content decides it, bind the element's own height through PillRadiusConverter:

<Window.Resources>
    <celeste:PillRadiusConverter x:Key="PillRadius" />
</Window.Resources>

<Border CornerRadius="{Binding RelativeSource={RelativeSource Self},
                               Path=ActualHeight,
                               Converter={StaticResource PillRadius}}" />

When Celeste is the wrong choice

  • You want Windows 11 Fluent. WPF-UI implements that design language properly, including Mica and the Fluent icon set. Celeste has its own look and will never match Windows chrome.
  • You want Material Design. Use MaterialDesignInXamlToolkit.
  • You need a data grid, docking, ribbons, or charts. Celeste has none of these and is not trying to become a control suite.
  • You are on .NET Framework. The package targets net8.0-windows and net10.0-windows only.

Status

Alpha. The token names and the public control API are still open to change; treat 0.x releases as breaking. What exists is covered by tests that apply every control's template in both themes on a real WPF dispatcher, so the styles load and measure — but they have not been through a wide range of real applications yet.

Not covered yet: Menu / ContextMenu, DataGrid, TreeView, Expander, Calendar / DatePicker, and dialogs. Those controls keep their default WPF appearance in a Celeste application, which is visible and jarring. Contributions in that direction are the most useful ones right now. Two areas are filled in: navigation — Sidebar, a SidebarHost that presents it docked, as a rail, or off-canvas, and a NavigationHost that holds pages and a back stack — and pictures, with ImageView, Avatar, and MasonryPanel.

ROADMAP.md has the order those gaps are being closed in, plus what is deliberately out of scope. CHANGELOG.md has what changed since the last published package.

Repository layout

Path Contents
src/Celeste.Wpf The library. Custom controls in Controls/, styles and tokens in Themes/.
samples/Celeste.Gallery A WPF app showing every control in both themes. dotnet run --project samples/Celeste.Gallery
tests/Celeste.Wpf.Tests Theme parity and template-application tests.

Building

dotnet build
dotnet test
dotnet run --project samples/Celeste.Gallery

Requires Windows and a .NET 10 SDK, 10.0.100 or newer — global.json asks for that version with rollForward: latestFeature, so any later release band works too. The library itself also builds for net8.0-windows.

Contributing

See CONTRIBUTING.md. New control styles need an entry in the gallery and a row in the template-application test; that is what keeps a style from silently breaking in one of the two themes.

License

MIT. See LICENSE.

About

A UI library for WPF applications that need to look current without adopting Fluent or Material.

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages