MAUI XAML UI Design MVVM

Crafting a Logic Dynamics Branded UI in .NET MAUI from Scratch

AL
Azim Litanga
· February 1, 2025 · ⏱ 9 min read
Series: Logic Dynamics Web Development Projects #01 — Part 3 of 3

UI is not an afterthought

I've seen too many developer portfolio projects that work perfectly but look like they were built in 2003. Dark grey backgrounds, default blue buttons, Times New Roman text. Technically impressive. Visually forgettable.

If you're building a portfolio project, the UI is part of the craft. It signals attention to detail. It shows you care about the full experience — not just the backend architecture.

This article covers how I built the Logic Dynamics branded UI for the Chat App from scratch — in .NET MAUI with XAML. No third-party component libraries. No Figma-to-code tools. Just XAML, gradients, and a clear design system.

The Logic Dynamics design system

Before writing a single line of XAML, I defined the design tokens:

TokenHexRole
Deep Navy#0A0F1EPage background
Midnight#0D1B3ECard backgrounds
LD Blue#0057FFPrimary actions, sent bubbles
Cyber Cyan#00D4FFAccents, headers, highlights
Signal Red#FF003CLogo, alerts
Steel#4A6FA5Placeholder text, timestamps
Ghost White#E8EDF5Body text

These aren't random colors. They're a deliberate system. Every color has a purpose. When you apply them consistently, the app feels cohesive — like a real product, not a tutorial.

<!-- Background gradient: Deep Navy → Midnight -->
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
    <GradientStop Color="#0A0F1E" Offset="0.0"/>
    <GradientStop Color="#0D1B3E" Offset="1.0"/>
</LinearGradientBrush>

<!-- Button gradient: LD Blue → Cyber Cyan -->
<LinearGradientBrush StartPoint="0,0" EndPoint="1,0">
    <GradientStop Color="#0057FF" Offset="0.0"/>
    <GradientStop Color="#00D4FF" Offset="1.0"/>
</LinearGradientBrush>

The left-to-right gradient on buttons creates an energy effect — it draws the eye toward the action.

MAUI layout architecture

The page uses a four-row Grid:

<Grid RowDefinitions="Auto,Auto,*,Auto" Padding="0">
    <!-- Row 0: Header -->
    <!-- Row 1: Join Room Panel -->
    <!-- Row 2: Messages List (*= fills remaining space) -->
    <!-- Row 3: Input Bar -->
</Grid>
💡 The * on row 2 is critical It tells the messages list to consume all remaining vertical space after the header, join panel, and input bar take their fixed heights. Without it, the layout collapses.

The header establishes brand identity immediately — a two-column grid with the logo on the left and brand text on the right, creating three typographic levels: eyebrow, title, tagline.

<Grid ColumnDefinitions="Auto,*" ColumnSpacing="16">

    <!-- LD Logo in a cyan-bordered rounded square -->
    <Border Grid.Column="0"
            WidthRequest="64" HeightRequest="64"
            StrokeShape="RoundRectangle 16"
            StrokeThickness="1.5" Stroke="#0CC0DF"
            BackgroundColor="#06062E">
        <Image Source="ld_logo.png"
               WidthRequest="48" HeightRequest="48"
               HorizontalOptions="Center" VerticalOptions="Center"/>
    </Border>

    <!-- Brand text stack -->
    <VerticalStackLayout Grid.Column="1" VerticalOptions="Center" Spacing="2">
        <Label Text="⚡ LOGIC DYNAMICS"
               FontSize="11" FontAttributes="Bold"
               TextColor="#00D4FF" CharacterSpacing="4"/>
        <Label Text="Chat" FontSize="32" FontAttributes="Bold" TextColor="White"/>
        <Label Text="Real-time • Secure • Fast" FontSize="12" TextColor="#4A6FA5"/>
    </VerticalStackLayout>

</Grid>

CharacterSpacing="4" on the eyebrow label creates that wide-tracked uppercase look — a classic premium UI pattern that guides the eye from brand → product → value proposition in under a second.

The chat bubble system

Chat apps have a visual language: your messages on the right, theirs on the left. WhatsApp, iMessage, Telegram — they all follow this convention because it works cognitively.

I implemented this with three separate Border elements inside a Grid, each with its own IsVisible binding:

<Grid Padding="0,4">

    <!-- System message — centered pill -->
    <Border IsVisible="{Binding IsSystem}"
            HorizontalOptions="Center"
            StrokeShape="RoundRectangle 20" Padding="12,4"
            BackgroundColor="#1A2744">
        <Label Text="{Binding Content}" TextColor="#4A6FA5"
               FontSize="11" FontAttributes="Italic"/>
    </Border>

    <!-- Sent — right aligned, LD Blue gradient -->
    <Border IsVisible="{Binding IsSent}"
            HorizontalOptions="End" MaximumWidthRequest="280"
            StrokeShape="RoundRectangle 18,18,4,18"
            Margin="60,2,0,2">
        <Border.Background>
            <LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
                <GradientStop Color="#0057FF" Offset="0.0"/>
                <GradientStop Color="#0099FF" Offset="1.0"/>
            </LinearGradientBrush>
        </Border.Background>
    </Border>

    <!-- Received — left aligned, dark card -->
    <Border IsVisible="{Binding IsReceived}"
            HorizontalOptions="Start" MaximumWidthRequest="280"
            StrokeShape="RoundRectangle 18,18,18,4"
            Margin="0,2,60,2">
    </Border>

</Grid>

Notice the StrokeShape values — this is the detail that makes it feel like iMessage:

This is a subtle but powerful detail. It reinforces the spatial metaphor without any icons or labels. MaximumWidthRequest="280" prevents long messages from stretching full width.

Making bubbles know who's talking

public class ChatViewModel
{
    private string CurrentUser { get; set; } = string.Empty;

    // Locked in when the user joins
    ConnectCommand = new Command(async () =>
    {
        CurrentUser = UserName;
        await _chatService.ConnectAsync();
        await _chatService.JoinRoomAsync(RoomId, UserName);
    });

    // Set IsSent before adding to the collection
    private void OnMessageReceived(ChatMessage msg)
    {
        msg.IsSent = msg.SenderName == CurrentUser;
        MainThread.BeginInvokeOnMainThread(() => Messages.Add(msg));
    }
}
💡 Always use MainThread.BeginInvokeOnMainThread SignalR callbacks fire on a background thread. Updating a UI-bound ObservableCollection from a background thread causes a crash on some MAUI platforms. Always marshal UI updates to the main thread.

The input bar

The input bar sits at the bottom, always visible. Wrapped in a gradient Border for polish:

<Border Grid.Row="3" Margin="16,8,16,24"
        StrokeShape="RoundRectangle 16" StrokeThickness="1"
        Stroke="#1A2744" Padding="4">
    <Border.Background>
        <LinearGradientBrush StartPoint="0,0" EndPoint="1,0">
            <GradientStop Color="#0D1B3E" Offset="0.0"/>
            <GradientStop Color="#112244" Offset="1.0"/>
        </LinearGradientBrush>
    </Border.Background>

    <Grid ColumnDefinitions="*,Auto" ColumnSpacing="8" Padding="8,4">
        <Entry Grid.Column="0"
               Placeholder="Type a message..."
               Text="{Binding MessageInput}"
               ReturnCommand="{Binding SendCommand}"
               BackgroundColor="Transparent"
               TextColor="White"/>

        <Border Grid.Column="1" StrokeShape="RoundRectangle 10"
                WidthRequest="80" HeightRequest="40">
            <Border.Background>
                <LinearGradientBrush StartPoint="0,0" EndPoint="1,0">
                    <GradientStop Color="#0057FF" Offset="0.0"/>
                    <GradientStop Color="#00D4FF" Offset="1.0"/>
                </LinearGradientBrush>
            </Border.Background>
            <Button Text="Send 🚀" Command="{Binding SendCommand}"
                    BackgroundColor="Transparent"/>
        </Border>
    </Grid>
</Border>

ReturnCommand="{Binding SendCommand}" wires the keyboard's return key to send — essential for mobile UX. BackgroundColor="Transparent" on the Entry is critical: without it, MAUI renders a default white or grey background that fights the gradient border behind it.


Key MAUI UI patterns I used

PatternWhy it matters
Gradient on BorderMAUI doesn't support gradient backgrounds on most elements. Wrap in a Border with LinearGradientBrush.
Transparent buttons inside gradient bordersSet BackgroundColor="Transparent" on Button, otherwise the button's default background covers the gradient.
#AARRGGBB for opacityMAUI doesn't support CSS-style rgba(). Use hex with alpha: #99FFFFFF = white at 60%.
MaximumWidthRequestSets an upper bound — element shrinks for short content and caps for long. WidthRequest sets a fixed width.
A branded UI isn't just cosmetic — it's a signal. It says you think about the full product, not just the backend code that powers it.

The full source code is at github.com/LogicDynamics/ChatApp. This wraps up the three-part series on the Logic Dynamics Chat App.

← Previous
From Localhost to the Internet: Deploying a .NET API to a Linux VPS
AL
Azim Litanga

Founder of LogicDynamics — .NET engineer, builder, and creator. Passionate about Blazor, Web APIs, AI, and shipping real software. Based in Oklahoma City.