Earlier today and in a non web-related conversation, I saw someone talk about copying tabular data into different formats, like plain text and CSV. It got me thinking - this could be a useful feature for web sites and surely something pretty trivial to do in JavaScript. So I whipped up a quick demo.
First, I added a table of course:
<table id="table1">
<thead>
<tr>
<th>Name</th>
<th>DOB</th>
<th>Gender</th>
<th>Breed</th>
</tr>
</thead>
<tbody>
<tr><td>Whiskers</td><td>2019-03-14</td><td>Male</td><td>Maine Coon</td></tr>
<tr><td>Luna</td><td>2020-07-22</td><td>Female</td><td>Siamese</td></tr>
<!-- bunch more rows -->
<tr><td>Nala</td><td>2022-04-18</td><td>Female</td><td>Persian</td></tr>
<tr><td>Molly</td><td>2022-01-11</td><td>Female</td><td>LaPerm</td></tr>
</tbody>
</table>
Alright, so to enable this feature, I thought data attributes might be nice. This would let you use it on anything, a button, a link, image, etc. The data attribute would be responsible for pointing to the table (via an id) and specifying a format. Here's an example:
<!-- data-copy defaults to text -->
<a href="" data-table="table1">Copy as Text</a> ~
<a href="" data-table="table1" data-copy="md">Copy as Markdown</a> ~
<a href="" data-table="table1" data-copy="csv">Copy as CSV</a>
Now all I need to do is wire it up to code that looks for data-table and adds the appropriate handlers. The first part is trivial:
const links = document.querySelectorAll('*[data-table]');
Note that while I named the variable links, you can still use this with buttons or images.
I loop over each match and first get the table referenced, throwing an error in console if it doesn't exist:
links.forEach(l => {
let table = document.querySelector(`#${l.dataset.table}`);
if(!table) {
console.error(`Unable to connect to table with id ${l.dataset.table}`);
return;
}
Next, I check the type, defaulting to text:
let type = l.dataset.copy || 'text';
The final bit is to assign the click handler:
l.addEventListener('click', async e => {
e.preventDefault();
console.log(`about to copy table ${table.id} to ${type}`);
let data = getRawData(table);
let formattedData = formatData(data, type);
await navigator.clipboard.writeText(formattedData);
});
For each, I get the raw data from the HTML table, convert it to the right format, and then write to the clipboard. (Check out my article on working with the clipboard in JavaScript.)
getRawData just iterates through the table's DOM creating a 2D array:
const getRawData = t => {
let data = [];
t.querySelectorAll('tr').forEach(r => {
let row = [];
r.querySelectorAll('th, td').forEach(d => {
row.push(d.innerText);
});
data.push(row);
});
return data;
}
formatData is a bit more complex and I used AI to help me with each of the main sections. I added support for plain text, Markdown, and CSV:
const formatData = (data,type) => {
let numCols, colWidths, lines, buildRow, buildDivider;
switch(type) {
case "text":
numCols = Math.max(...data.map(r => r.length));
// Compute max width for each column
colWidths = Array(numCols).fill(0);
for (const row of data) {
for (let i = 0; i < numCols; i++) {
const cell = row[i] !== undefined ? String(row[i]) : '';
colWidths[i] = Math.max(colWidths[i], cell.length);
}
}
buildDivider = () =>
'+' + colWidths.map(w => '-'.repeat(w + 2)).join('+') + '+';
buildRow = (row) =>
'|' + colWidths.map((w, i) => {
const cell = row[i] !== undefined ? String(row[i]) : '';
return ' ' + cell.padEnd(w) + ' ';
}).join('|') + '|';
lines = [];
lines.push(buildDivider());
lines.push(buildRow(data[0])); // treat first row as header
lines.push(buildDivider());
for (let i = 1; i < data.length; i++) {
lines.push(buildRow(data[i]));
}
lines.push(buildDivider());
return lines.join('\n');
case "md":
case "markdown":
numCols = Math.max(...data.map(r => r.length));
buildRow = (row) =>
'| ' + Array.from({ length: numCols }, (_, i) =>
row[i] !== undefined ? String(row[i]) : ''
).join(' | ') + ' |';
lines = [];
lines.push(buildRow(data[0])); // header
lines.push('| ' + Array(numCols).fill('---').join(' | ') + ' |');
for (let i = 1; i < data.length; i++) {
lines.push(buildRow(data[i]));
}
return lines.join('\n');
case "csv":
const escapeCell = (value) => {
const str = value !== undefined && value !== null ? String(value) : '';
// Quote if it contains comma, quote, or newline
if (/[",\n\r]/.test(str)) {
return '"' + str.replace(/"/g, '""') + '"';
}
return str;
};
return data.map(row => row.map(escapeCell).join(',')).join('\n');
}
}
With my test table (and I'm stripping out a bit here for brevity), here's the plain text version:
+----------+------------+--------+----------------------+
| Name | DOB | Gender | Breed |
+----------+------------+--------+----------------------+
| Whiskers | 2019-03-14 | Male | Maine Coon |
| Luna | 2020-07-22 | Female | Siamese |
| Oliver | 2018-11-05 | Male | British Shorthair |
| Max | 2019-06-25 | Male | Sphynx |
| Stella | 2021-05-04 | Female | American Curl |
| Rocky | 2018-09-20 | Male | Burmese |
| Molly | 2022-01-11 | Female | LaPerm |
+----------+------------+--------+----------------------+
Here's the Markdown:
| Name | DOB | Gender | Breed |
| --- | --- | --- | --- |
| Whiskers | 2019-03-14 | Male | Maine Coon |
| Luna | 2020-07-22 | Female | Siamese |
| Oliver | 2018-11-05 | Male | British Shorthair |
| Max | 2019-06-25 | Male | Sphynx |
| Stella | 2021-05-04 | Female | American Curl |
| Rocky | 2018-09-20 | Male | Burmese |
| Molly | 2022-01-11 | Female | LaPerm |
And here's CSV:
Name,DOB,Gender,Breed
Whiskers,2019-03-14,Male,Maine Coon
Luna,2020-07-22,Female,Siamese
Oliver,2018-11-05,Male,British Shorthair
Max,2019-06-25,Male,Sphynx
Stella,2021-05-04,Female,American Curl
Rocky,2018-09-20,Male,Burmese
Molly,2022-01-11,Female,LaPerm
I wrapped this all up in a function that you could add to your page. After adding it, don't forget to actually add the links/button/etc to enable it.
There's two issues with my code that come to mind, and I'd love people to comment in with their ideas. First, both the text and Markdown assume the first row is a header. In theory that might not always be accurate. I could update the code to see if the first cell in the first row is th versus td, but that kinda feels like overkill.
The second issue that comes to mind is user feedback. Usually these types of things will provide some kind of visual feedback ("Data copied!"). My code does not. I honestly don't quite know how I'd do that. Perhaps a data attribute that would replace the text (assuming it's a link or button) temporarily? I'm not sure.
If you've got ideas, leave me a comment below. Here's the demo:
See the Pen Copy Table As (2) by Raymond Camden (@cfjedimaster) on CodePen.
How do you figure out how your app idea should feel to use? Or which part to build first? Turns out we can draw inspiration from some classic texts on software development.
In Parts 1 and 2, we used AI to help us get clear about what we’re building and why. We scoped it down and were explicit about what’s not in scope. This left us with some decisions made and a starting point for the next step.
But what is that step? Well that depends slightly on what you’re trying to do.
In Part 1, we identified that software design is messy. Reaching a shared understanding about what you’re building (shared with other humans or AI) is a challenge. What we definitely don’t want to do is give AI a giant spec and tell it to “go build it.” There are too many unknowns and opportunities for AI to drift from what we really wanted in the first place.
So how do we break this down into smaller steps and stay agile so we can react to reality when it hits us in the face and demands we change direction?
It turns out there are answers to be found in the classic software development texts. The Pragmatic Programmer (a book you absolutely should read, especially if you’re using AI for coding) talks about tracer bullets and prototypes. They are two different tools for slightly different purposes.
A tracer bullet is a bullet that’s loaded at intervals alongside regular bullets and leaves a trace when fired. The idea is it helps you locate the target (if you see the tracer bullets hitting a target, so are the other bullets).
In coding terms, we can use this to build something that gives you early feedback that you’re aiming at the right target.
In a project like this, a useful tracer bullet might be a minimal feature that exercises the entire stack (UI to backend to persistent storage). Or maybe that first stage of setting up the web project, running it and checking it loads in the browser.
The key with the tracer bullet is that it’s production code, built vertically, to verify your basic premise holds up.
Prototypes are different. They’re useful for testing out UI designs or spiking technical choices that you haven’t used before. You can use them for everything from generating multiple versions of a screen (UI only, no backend) to settle on a design/UX, to spiking out how Claude’s Agent SDK could work to give your app AI capabilities.
The big difference between prototypes and tracer bullets is that prototypes are throwaway. Once you’ve used them to clarify an aspect of your project, you chuck them in the bin. Tracer bullets, on the other hand, are small vertical slices of your app that you then keep and build on over time.
In this case, I wanted to think through the UI and UX for this new app. Specifically to figure out the ergonomics of everything we decided was in scope (in Part 2).
Now I want to prototype this. To be clear, this prototype is for deciding on the rough shape of the UI for this feature. I would like to see a rough mockup (not over-designed) of how this article workspace could look. This is prototype code which will be thrown away, so does not need to be functional beyond showing me potential UI/UX
Again, we’re starting with a prompt to see how Codex (GPT 5.5) handles this. From that prompt, it created a basic V1, which was surprisingly functional but had a few issues.

The issues here include:
But the markdown editor works, as does the left-hand nav, and I liked the idea of the publish checks (which we had surfaced in earlier conversations with the AI).
So, over a few messages, we iterated this prototype:
Capture an idea that is way too small to be useful. I think I’d be tempted to have that as a button, and a modal in this case (not always a fan of a modal, but seems OK here)
It then built a new modal for capturing an idea, but it added more fields. This is a trap with LLMs trying to be helpful (drifting from the brief), so I brought it back.
we’ve drifted from the brief there, we agreed to simply capture a rough idea/title and also optionally why it matters. So the idea should be a text area (rough idea) too and we don’t need additional fields
After a few more back-and-forths, we arrived at a better version, but I wanted to check we’d met the requirements listed in the most recent ADR (created in the last step).
if you review the ADR, what does this screen not meet (in terms of the requirements we landed on)
After a couple more iterations we landed here:

Key features that came out of this process:
The key thing that jumped out for me here was the realization that, although the ADR captured the details of what we were aiming for, it was only during this “mockup” stage that some key design decisions were made. It’s another example of using AI to help you, the human, think.
Because this is throwaway code, it’s cheap to iterate, throw ideas around and think through the ergonomics of this feature. This is not 1:1 production code, but it helped us arrive at some key UI/UX decisions, and we can always use this as a reference point when we come to build the real thing. It will be up to us how closely we want the AI to stick to this “design.”
With that UI sorted, the next step is to make this something that runs in the browser, and as quickly as possible. A perfect case for tracer bullets!
using the prototype we settled on, please identify candidates for our first tracer bullet and present them to me. This should be a tracer bullet as in the Pragmatic Programmer, something we can build that’s real, and will be the basis of our app
At this point, GPT gave me a few options:
As this new UI will need to work with MDX files in my Astro blog as the source of truth, I figured “Open Existing Draft and Save Edits” was a good first step (proves the architecture and immediately gives me a new editor for existing content).
GPT suggested this acceptance test:
Given an existing Astro MDX draft, I can open the app, edit the article body in a comfortable editor, save it, close the app, reopen it, and see the same edited draft loaded from the real Astro source file.
At this stage, we need something to build from (which is a little more detailed than that acceptance test).
Ok great let’s capture that. I feel like we should create a task/ticket/issue for this.
With that GPT created a task md file. Here’s part of it:

This is all about context. We want to give AI enough context to do the work, but not so much it has to start guessing and interpreting mountains of detail to find the parts it needs.
This is a good first step. Next time, we’ll build that tracer bullet for real.
You’ve probably seen a healthcare interface when you were scheduling a recent doctor’s appointment. Let’s see how to build one in .NET MAUI!
One of the biggest challenges when creating app interfaces is when design elements step a bit outside the conventional. These kinds of designs push us to think differently and get more creative when translating them into code.
In this article, I’m bringing you a healthcare UI inspired by this Dribbble doctor appointment UI design. I have to admit, it’s one of my favorites! It gives us the perfect opportunity to apply some cool tricks that make our design look impressive and truly out of the box.
We’ll be using some Progress Telerik UI for .NET MAUI components, along with a CollectionView, but this time, we’ll simulate different columns to achieve the layout we want. Plus, we’ll rely on three properties that will become our best friends in this UI: Rotation, TranslationY and TranslationX.
So grab your cup of coffee or hot chocolate ☕ and let’s build this together! ✨
So you can understand where we’re headed, I’ll explain how the process will work:
➖ Visual diagram: Before we start, you’ll see a diagram with a screenshot of the design we want to achieve. This design will be divided into blocks, each identified with a name and a color. Each block will be explained individually.
➖ Code explanation per block: In addition to the text explanation, each block will include the exact code snippet you need to write to achieve the result.
➖ Step-by-step visual progress: Each explanation will have its corresponding screenshot, so you can see the progress as we build the UI.
⚠️ For this design, we took inspiration from an original Dribbble UI but made some adaptations and adjustments to align it with the goals of this article.
You’ll learn how to take advantage of some of the powerful Telerik UI for .NET MAUI components in this post. If you don’t have the .NET MAUI library installed yet, visit the quick start page, which explains step-by-step how to install Telerik UI for .NET MAUI, download the license key, configure the Telerik package source and install the Telerik MCP server (if needed).
To make this easier to follow, I’ve divided the design into clear blocks. We’ll build each part one at a time, in the order you see below.

Alright, now that we’ve broken our UI into clear blocks, it’s time to bring everything to life with some code! Let’s start building each section step by step.

This first block is one of my favorites, as it brings a unique touch to the UI. It plays with the way the information is presented and introduces a slightly different style for the Get Started button. Since this block represents a full page, we’ll break this explanation down into the following steps:
Let’s start by creating a page called DoctorIntroPage.xaml. This page will contain a Grid as the main layout. The grid will have only one row, which will allow us to play with the overlapping effect between the background image and the rest of the elements. Right there, you’ll also see exactly where the background image is added.
<Grid RowDefinitions="*">
<!-- Main image -->
<Image Grid.Row="0" Source="doctor.jpg" Aspect="AspectFill" Margin="0,40,0,0"/>
<!-- Add the rest of the code for the first block -- >
</Grid>
To continue with these elements, we’ll add another Grid. This one will contain three rows and two columns:
<Grid Grid.Row="0" RowDefinitions="auto,*,auto,auto" ColumnDefinitions="*,*" Padding="20,40,20,30">
<!-- Add the rotated cards here -- >
<!-- Add the Get Started button here -- >
</Grid>
⚠️ Add the previous code exactly in the comment above that says
<!-- Add the rest of the code for the first block -->.
Now, to add the cards, we’ll use one of my favorite Telerik controls: Telerik Border for .NET MAUI. Make sure you follow the steps indicated in the Environment Setup section at the beginning of the article. After that, you just need to add the corresponding namespace:
xmlns:telerik="clr-namespace:Telerik.Maui.Controls;assembly=Telerik.Maui.Controls"
To achieve the card effect, I mainly relied on the Rotation property. One card uses a value of 50, while the other uses -12, creating the effect of opposite rotations between them. In addition, I used the TranslationX and TranslationY properties to position the cards and make the overlapping effect more noticeable. In code, it would look like this:
<telerik:RadBorder Grid.Row="1" Grid.Column="0"
BackgroundColor="#f4f5fe"
CornerRadius="20"
HeightRequest="190"
WidthRequest="190"
TranslationX="20"
Rotation="5">
<Grid RowDefinitions="auto,*,auto" ColumnDefinitions="*,auto" Padding="15">
<telerik:RadButton Grid.Row="0" Grid.Column="1" ImageSource="cardiogram" BackgroundColor="Black" HeightRequest="60" WidthRequest="60" CornerRadius="30"/>
<Label Grid.Row="1" Grid.Column="0" Text="Strengthen" FontAttributes="Bold" FontSize="16"/>
<Label Grid.Row="2" Grid.Column="0" Text="Support your heart with care and healthy lifestyle." FontSize="11" />
</Grid>
</telerik:RadBorder>
<telerik:RadBorder Grid.Row="1" Grid.Column="1"
BackgroundColor="#3d7ff5"
CornerRadius="20"
HeightRequest="190"
WidthRequest="190"
TranslationY="90"
TranslationX="-10"
Opacity="0.8"
Rotation="-12">
<Grid RowDefinitions="auto,*,auto" ColumnDefinitions="auto,*" Padding="15">
<telerik:RadButton Grid.Row="0" Grid.Column="0" ImageSource="stethoscope" BackgroundColor="White" HeightRequest="60" WidthRequest="60" CornerRadius="30"/>
<Label Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="2" TextColor="White" Text="Safeguard" FontAttributes="Bold" HorizontalTextAlignment="End" FontSize="16" />
<Label Grid.Row="2" Grid.Column="0" Grid.ColumnSpan="2" TextColor="White" Text="Reduce risks with regular check-ups and early ditention." HorizontalTextAlignment="Start" FontSize="11" />
</Grid>
</telerik:RadBorder>
To achieve this, we’ll use a Telerik Border as a container to create a rounded black background behind the button. Inside it, we’ll place a Telerik RadButton.
Since we’ve already added the namespace for RadBorder, you’re all set, there’s no need to add anything else to use RadButton.
<telerik:RadBorder Grid.Row="2" Grid.Column="0" Grid.ColumnSpan="2"
BackgroundColor="Black"
Opacity="0.9"
CornerRadius="50"
HeightRequest="70" >
<Grid ColumnDefinitions="*,auto" Padding="15,5">
<Label Grid.Column="0" Text="Get Started" FontSize="15" TextColor="White" VerticalTextAlignment="Center" />
<telerik:RadButton Grid.Column="1" ImageSource="uprightarrow" BackgroundColor="#3d7ff5" HeightRequest="50" WidthRequest="50" CornerRadius="25" HorizontalOptions="End" Margin="10"/>
</Grid>
</telerik:RadBorder>
We’ve finished this first block, and we already have our first page created! Now, let’s move on to the second block and keep building!

We’ve finished this first section, and our first page is already up and running! Now, let’s move on to the second section.
This time, we’re going to create a page called DoctorDetailsPage.xaml. Set the background color of this page to #f4f5fe and let’s define the main layout using a Grid. This Grid will have five rows and three columns, as shown below:
<Grid RowDefinitions="auto,auto,auto,auto,auto,*"
ColumnDefinitions="*,auto,auto"
Padding="20,30,20,0">
<!-- Add the header block here -- >
<!-- Add the appointment block here -- >
<!-- Add the available doctors block here -- >
</Grid>
Now, within the Grid, let’s add the name and email:
<Label Grid.Row="0" Grid.Column="0" Text="Ronalds S." FontSize="24" FontAttributes="Bold" />
<Label Grid.Row="1" Grid.Column="0" Text="ronalds@gmail.com" FontSize="14"/>
Next, we’ll add a Telerik RadBorder control (remember to include the corresponding namespace, just like we did on the previous page). Inside it, we’ll place an image along with a rounded button:
<telerik:RadBorder Grid.Row="0" Grid.RowSpan="2" Grid.Column="1" Grid.ColumnSpan="2"
Margin="0,0,0,40"
BackgroundColor="White"
CornerRadius="120"
VerticalOptions="Start">
<HorizontalStackLayout>
<telerik:RadButton ImageSource="plus" BackgroundColor="#ebecfb" HeightRequest="60" WidthRequest="60" CornerRadius="30" Margin="5,2,0,1"/>
<Image Source="doctoravatar" HeightRequest="60" WidthRequest="60" Aspect="AspectFill" Margin="5,2"/>
</HorizontalStackLayout>
</telerik:RadBorder>

The Appointment block contains different elements, which are achieved by adding a few Label controls along with a Telerik RadBorder, as shown below:
<Label Grid.Row="2" Grid.Column="0" Text="Today" FontSize="14" />
<Label Grid.Row="3" Grid.Column="0" Text="Appointment" FontAttributes="Bold" FontSize="16" />
<Label Grid.Row="2" Grid.Column="2" Text="See all" HorizontalTextAlignment="End" TextColor="#3d7ff5" FontSize="14" />
<telerik:RadBorder Grid.Row="4" Grid.Column="0" Grid.ColumnSpan="3"
BackgroundColor="#3d7ff5"
CornerRadius="120"
Margin="0,10,0,0"
HeightRequest="80" AutomationProperties.IsInAccessibleTree="True">
<Grid ColumnDefinitions="auto,*" RowDefinitions="auto,auto,auto" VerticalOptions="Center">
<Image Grid.Column="0" Grid.Row="0" Grid.RowSpan="3" Source="doctorjose" HeightRequest="80" WidthRequest="80" Aspect="AspectFill" Margin="5,2"/>
<Label Grid.Column="1" Grid.Row="0" Text="March 21, 10:00 AM" FontSize="10" TextColor="White" VerticalTextAlignment="End" />
<Label Grid.Column="1" Grid.Row="1" Text="Dr. Suzanne Holroyd" FontSize="16" FontAttributes="Bold" TextColor="White"/>
<Label Grid.Column="1" Grid.Row="2" Text="Pulmonology" FontSize="10" TextColor="White" VerticalTextAlignment="Start" />
</Grid>
</telerik:RadBorder>

For our final block, we’ll also use a Telerik RadBorder to simulate a white background with rounded top corners. Inside it, we’ll add a Grid with two rows and three columns. Within this layout, we’ll include the title label, two rounded buttons and, finally, a CollectionView.
<telerik:RadBorder Grid.Row="5" Grid.Column="0" Grid.ColumnSpan="3"
VerticalOptions="Fill"
BackgroundColor="White"
CornerRadius="30,30,0,0"
Margin="-20,20,-20,0">
<Grid RowDefinitions="auto,*" ColumnDefinitions="*,auto,auto" Padding="20,30,0,0">
<Label Grid.Row="0" Grid.Column="0" Text="Select Visit Type" FontSize="15" VerticalTextAlignment="End" FontAttributes="Bold"/>
<telerik:RadButton Grid.Row="0" Grid.Column="1" ImageSource="search" BackgroundColor="#ebecfb" HeightRequest="50" WidthRequest="50" CornerRadius="25" Margin="0,0,5,0" />
<telerik:RadButton Grid.Row="0" Grid.Column="2" ImageSource="filter" BackgroundColor="#ebecfb" HeightRequest="50" WidthRequest="50" CornerRadius="25" />
<CollectionView Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="3"
ItemsSource="{Binding specialists}">
<CollectionView.ItemsLayout>
<GridItemsLayout Orientation="Horizontal" HorizontalItemSpacing="15" Span="2"/>
</CollectionView.ItemsLayout>
<CollectionView.ItemTemplate>
<DataTemplate>
<telerik:RadBorder BackgroundColor="{Binding bgColor}"
CornerRadius="20"
HeightRequest="150"
WidthRequest="150">
<Grid RowDefinitions="auto,auto,*,*" ColumnDefinitions="*,auto" Padding="15">
<telerik:RadButton Grid.Row="0" Grid.Column="1" Grid.RowSpan="2" ImageSource="rightupblack" BackgroundColor="{Binding btnColor}" HeightRequest="50" WidthRequest="50" CornerRadius="25"/>
<Label Grid.Row="0" Grid.Column="0" Text="{Binding Price}" FontAttributes="Bold" FontSize="16" TextColor="{Binding txColor}"/>
<Label Grid.Row="1" Grid.Column="0" Text="Per visit" FontAttributes="Bold" FontSize="10" TextColor="{Binding txColor}"/>
<Label Grid.Row="2" Grid.Column="0" Grid.ColumnSpan="2" Text="{Binding category}" TextColor="{Binding txColor}" FontAttributes="Bold" FontSize="12" VerticalTextAlignment="End"/>
<Label Grid.Row="3" Grid.Column="0" Grid.ColumnSpan="2" Text="{Binding description}" TextColor="{Binding txColor}" FontAttributes="Bold" FontSize="10"/>
</Grid>
</telerik:RadBorder>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</Grid>
</telerik:RadBorder>
✍️ Yes! You might notice something different in the CollectionView. Instead of a traditional list, the items are displayed in multiple columns. This is achieved by using a GridItemsLayout and setting the Span property, which allows us to organize the results into columns.
And that’s it—we’re all done! Below, you can see the final result of both screens.

And that’s it! In this article, we explored how to build a doctor’s office app UI using .NET MAUI with XAML. We walked through how to structure the UI into blocks, create designs using grids, apply overlapping effects and enhance the experience using Telerik components like RadBorder and RadButton.
I hope this article helps you take these ideas and apply them to your own projects!
If you have any questions or would like me to dive deeper into a specific part, feel free to leave a comment—I’ll be happy to help! See you in the next article! ♀️
Remember, Telerik UI for .NET MAUI comes with a free 30-day trial. So you can play around in the meantime!
C++ dependency setup and maintenance still slows down many teams. In my Pure Virtual C++ 2026 session, I show a terminal-first workflow that ends with a working CLI app that formats and prints programming quotes, built with CMake and Microsoft C++ (MSVC) Build Tools. All of this is powered by GitHub Copilot CLI, which does the work of generating the code and project files, identifying appropriate open-source library dependencies, installing the vcpkg package manager, and integrating the libraries into the project.
GitHub Copilot CLI is the command-line implementation for GitHub Copilot, allowing you to run agents in a terminal window. You can use it as an AI chat interface, give it work to do semi- or fully autonomously, optionally across multiple parallel subagents. For C++ developers, Copilot CLI is a valuable tool for work you would be comfortable doing in the command line yourself, from generating code to compiling projects and debugging issues. You can persist and manage Copilot sessions, customize your environment with plugins, custom agents, MCP servers, skills, hooks, and models, and seamlessly switch from working in the command line to using Copilot in another environment like Visual Studio Code (VS Code). Once you get into the groove, using it feels as natural as a web browser or an IDE.
This walkthrough will show you the following:
vcpkg.json, and restore is part of your normal configure/build flow.builtin-baseline to pin dependencies for reproducible builds while retaining the flexibility to upgrade later without introducing ABI-breaking changes in your dependency graph.This walkthrough starts from the first prompt and ends with a successful build: start in an empty folder, stay in the terminal, and avoid hand-managed include and link paths.
This section is written so you can follow along with the video demo.
Before you get started, make sure your environment is set up and ready to go:
mkdir quotecli). Add a subfolder called data with a quotes.json file that looks something like this:
{
"quotes": [
{ "text": "Any sufficiently advanced technology is indistinguishable from magic.", "author": "Arthur C. Clarke" },
{ "text": "Simplicity is prerequisite for reliability.", "author": "Edsger W. Dijkstra" },
{ "text": "Controlling complexity is the essence of computer programming.", "author": "Brian Kernighan" },
{ "text": "That brain of mine is something more than merely mortal, as time will show.", "author": "Ada Lovelace" },
{ "text": "First, solve the problem. Then, write the code.", "author": "John Johnson" },
{ "text": "The most important property of a program is whether it accomplishes the intention of its user.", "author": "C.A.R. Hoare" },
{ "text": "Programs must be written for people to read, and only incidentally for machines to execute.", "author": "Harold Abelson" },
{ "text": "Premature optimization is the root of all evil.", "author": "Donald Knuth" }
]
}
copilot. 
/plugin install cpp-language-server@copilot-plugins./model. I recommend starting with “Auto”, which lets Copilot select an appropriate model per task (optimizing between token cost and correctness). You can also manually select a specific model./skill add local-path-to-skill. Note: This skill isn’t officially maintained by Microsoft; it’s there to get you started with a customizable workflow, should you want it.Begin by typing Shift+Tab until you transition Copilot into Plan Mode. This mode causes Copilot to discuss an implementation plan with you before it begins working, and is useful if you want to use Copilot on something complex, such as a multi-step workflow. This allows you to check what Copilot wants to do before doing it, so you can intervene and correct it if necessary.
Next, type a prompt similar to the following:
I want to write a C++ console application that prints a random software development quote with pretty formatting from a list specified in data/quotes.json. I want to build with CMake and MSVC with some open-source libraries from vcpkg. I already have both CMake and MSVC installed, but don't have vcpkg. Install vcpkg and include it in this directory for use. I don't know what C++ libraries to use, help me work that out too.
Expected result: Copilot produces a plan describing what it wants to build, how the project will be structured, the expected behavior of the app, and docs (README file).
Once Copilot has prepared the plan, if you are satisfied with it, you can select Accept plan and build on default permissions. This allows Copilot to begin working, while continuing to ask your permission to run commands and modify files. You could instead choose Accept plan and build on autopilot if you want Copilot to work autonomously and notify you when it finishes. You can also suggest changes if you’re not happy with the plan, and iterate until you’re ready to proceed.
Expected result: Copilot CLI proposes library choices, sets up a CMake project, installs and initializes vcpkg in the repo, creates a vcpkg.json manifest, and builds the project to test that there are no errors. It may iterate on its work several times as it identifies new issues that need to be addressed (e.g. selecting the appropriate CMake generator for what you have installed on your system).
You can ask Copilot to explain its decisions or to give you a better understanding of the resulting project. Here are some example prompts:
Explain each library you chose, what problem it solves in this app, and where we use it.
Can you give me some boilerplate code for using <library> in a similar context?
How do I build and run this project?
Expected result: you get a short technical explanation for the topic(s) you want explained. Based on the answers, you can decide whether to instruct Copilot to make further changes.
In a separate terminal window/tab, configure CMake, build, and run:
cmake --preset msvc-debug
cmake --build --preset build-debug
.\build\msvc-debug\bin\Debug\quotecli.exe

Expected result: Program runs successfully, generating a random quote each time it is run from the list of available quotes.
Now suppose you want to set and pin your library versions to achieve reproducible builds. Once again, Copilot can guide you once you instruct it with a simple prompt:
I want to pin my dependencies to the latest official vcpkg release so I can achieve reproducible builds. How can I do that in vcpkg?
Expected result: Copilot is able to add a builtin-baseline field to vcpkg.json, choosing a reasonable baseline based on the latest official vcpkg release, and provide you with guidance on what it all means and how to maintain it going further. You can ask Copilot to clarify specific topics along the way.
At the end of all this, your project should have a layout similar to this:
vcpkg.json with your dependency list and builtin-baseline.CMakeLists.txt with find_package(...) entries and linked targets.CMakePresets.jsonsrc/main.cpp.data/quotes.json.Directory layout:
quotecli
├── build
├── data
│ └── quotes.json
├── src
│ └── main.cpp
├── vcpkg
│ └── <vcpkg files>
├── CMakeLists.txt
├── CMakePresets.json
├── README.md
└── vcpkg.json
This is what each of the major files looks like in my demo:
quotes.json
{
"quotes": [
{ "text": "Any sufficiently advanced technology is indistinguishable from magic.", "author": "Arthur C. Clarke" },
{ "text": "Simplicity is prerequisite for reliability.", "author": "Edsger W. Dijkstra" },
{ "text": "Controlling complexity is the essence of computer programming.", "author": "Brian Kernighan" },
{ "text": "That brain of mine is something more than merely mortal, as time will show.", "author": "Ada Lovelace" },
{ "text": "First, solve the problem. Then, write the code.", "author": "John Johnson" },
{ "text": "The most important property of a program is whether it accomplishes the intention of its user.", "author": "C.A.R. Hoare" },
{ "text": "Programs must be written for people to read, and only incidentally for machines to execute.", "author": "Harold Abelson" },
{ "text": "Premature optimization is the root of all evil.", "author": "Donald Knuth" }
]
}
main.cpp:
#include <cctype>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <random>
#include <stdexcept>
#include <string>
#include <vector>
#include <fmt/core.h>
#include <nlohmann/json.hpp>
#include <rang.hpp>
namespace {
struct Quote {
std::string text;
std::string author;
};
std::vector<std::string> WrapText(const std::string& text, const std::size_t maxWidth) {
std::vector<std::string> lines;
std::string currentLine;
std::string currentWord;
auto flushWord = [&]() {
if (currentWord.empty()) {
return;
}
if (currentLine.empty()) {
currentLine = currentWord;
} else if (currentLine.size() + 1 + currentWord.size() <= maxWidth) {
currentLine += " " + currentWord;
} else {
lines.push_back(currentLine);
currentLine = currentWord;
}
currentWord.clear();
};
for (const char ch : text) {
if (std::isspace(static_cast<unsigned char>(ch)) != 0) {
flushWord();
} else {
currentWord.push_back(ch);
}
}
flushWord();
if (!currentLine.empty()) {
lines.push_back(currentLine);
}
if (lines.empty()) {
lines.emplace_back();
}
return lines;
}
std::filesystem::path ResolveQuotePath(const char* argv0) {
namespace fs = std::filesystem;
const fs::path cwdCandidate = fs::current_path() / "data" / "quotes.json";
if (fs::exists(cwdCandidate)) {
return cwdCandidate;
}
const fs::path executablePath = fs::absolute(fs::path(argv0)).parent_path();
const std::vector<fs::path> candidates = {
executablePath / "data" / "quotes.json",
executablePath.parent_path() / "data" / "quotes.json",
executablePath.parent_path().parent_path() / "data" / "quotes.json"
};
for (const auto& candidate : candidates) {
if (fs::exists(candidate)) {
return candidate;
}
}
throw std::runtime_error("Could not find data/quotes.json.");
}
std::vector<Quote> LoadQuotes(const std::filesystem::path& jsonPath) {
std::ifstream file(jsonPath);
if (!file) {
throw std::runtime_error(fmt::format("Failed to open '{}'.", jsonPath.string()));
}
nlohmann::json document;
file >> document;
if (!document.is_object() || !document.contains("quotes") || !document["quotes"].is_array()) {
throw std::runtime_error("Invalid JSON: expected an object with a 'quotes' array.");
}
std::vector<Quote> quotes;
for (const auto& entry : document["quotes"]) {
if (!entry.is_object() || !entry.contains("text") || !entry.contains("author") ||
!entry["text"].is_string() || !entry["author"].is_string()) {
throw std::runtime_error("Invalid quote entry: each quote must have string 'text' and 'author'.");
}
quotes.push_back({
entry["text"].get<std::string>(),
entry["author"].get<std::string>()
});
}
if (quotes.empty()) {
throw std::runtime_error("The quotes array is empty.");
}
return quotes;
}
const Quote& PickRandomQuote(const std::vector<Quote>& quotes) {
std::random_device randomDevice;
std::mt19937 generator(randomDevice());
std::uniform_int_distribution<std::size_t> distribution(0, quotes.size() - 1);
return quotes[distribution(generator)];
}
void PrintQuoteCard(const Quote& quote) {
constexpr std::size_t bodyWidth = 72;
const std::string border = "+" + std::string(bodyWidth + 2, '-') + "+";
const std::string title = " Random Software Dev Quote ";
const std::string authorLine = fmt::format("- {}", quote.author);
const auto wrappedQuote = WrapText(quote.text, bodyWidth);
rang::setControlMode(rang::control::Auto);
std::cout << rang::fgB::blue << border << rang::style::reset << '\n';
std::cout << rang::fgB::blue << "|"
<< rang::style::bold << rang::fgB::green
<< fmt::format("{:^{}}", title, bodyWidth + 2)
<< rang::style::reset
<< rang::fgB::blue << "|" << rang::style::reset << '\n';
std::cout << rang::fgB::blue << border << rang::style::reset << '\n';
for (const auto& line : wrappedQuote) {
std::cout << rang::fgB::blue << "| "
<< rang::style::italic << rang::fg::gray
<< fmt::format("{:<{}}", line, bodyWidth)
<< rang::style::reset
<< rang::fgB::blue << " |"
<< rang::style::reset << '\n';
}
std::cout << rang::fgB::blue << "| "
<< fmt::format("{:<{}}", "", bodyWidth)
<< " |" << rang::style::reset << '\n';
std::cout << rang::fgB::blue << "| "
<< rang::style::bold << rang::fgB::yellow
<< fmt::format("{:>{}}", authorLine, bodyWidth)
<< rang::style::reset
<< rang::fgB::blue << " |"
<< rang::style::reset << '\n';
std::cout << rang::fgB::blue << border << rang::style::reset << '\n';
}
} // namespace
int main(const int argc, char* argv[]) {
if (argc < 1 || argv == nullptr || argv[0] == nullptr) {
std::cerr << "Unable to resolve executable path." << '\n';
return 1;
}
try {
const auto quotePath = ResolveQuotePath(argv[0]);
const auto quotes = LoadQuotes(quotePath);
PrintQuoteCard(PickRandomQuote(quotes));
} catch (const std::exception& ex) {
std::cerr << fmt::format("Error: {}", ex.what()) << '\n';
return 1;
}
return 0;
}
CMakeLists.txt:
cmake_minimum_required(VERSION 3.21)
project(quotecli VERSION 0.1.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
find_package(nlohmann_json CONFIG REQUIRED)
find_package(fmt CONFIG REQUIRED)
find_package(rang CONFIG REQUIRED)
add_executable(quotecli src/main.cpp)
target_link_libraries(quotecli PRIVATE
nlohmann_json::nlohmann_json
fmt::fmt-header-only
rang::rang
)
if(MSVC)
target_compile_options(quotecli PRIVATE /W4 /permissive-)
else()
target_compile_options(quotecli PRIVATE -Wall -Wextra -Wpedantic)
endif()
set_target_properties(quotecli PROPERTIES
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin"
)
add_custom_command(TARGET quotecli POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_directory
"${CMAKE_SOURCE_DIR}/data"
"$<TARGET_FILE_DIR:quotecli>/data"
COMMENT "Copying quote data to output directory"
)
CMakePresets.json:
{
"version": 3,
"cmakeMinimumRequired": {
"major": 3,
"minor": 21,
"patch": 0
},
"configurePresets": [
{
"name": "msvc-debug",
"displayName": "MSVC Debug",
"generator": "Visual Studio 18 2026",
"binaryDir": "${sourceDir}\\build\\${presetName}",
"architecture": {
"value": "x64"
},
"cacheVariables": {
"CMAKE_TOOLCHAIN_FILE": "${sourceDir}\\vcpkg\\scripts\\buildsystems\\vcpkg.cmake",
"VCPKG_MANIFEST_MODE": "ON"
}
},
{
"name": "msvc-release",
"displayName": "MSVC Release",
"generator": "Visual Studio 18 2026",
"binaryDir": "${sourceDir}\\build\\${presetName}",
"architecture": {
"value": "x64"
},
"cacheVariables": {
"CMAKE_TOOLCHAIN_FILE": "${sourceDir}\\vcpkg\\scripts\\buildsystems\\vcpkg.cmake",
"VCPKG_MANIFEST_MODE": "ON"
}
}
],
"buildPresets": [
{
"name": "build-debug",
"configurePreset": "msvc-debug",
"configuration": "Debug"
},
{
"name": "build-release",
"configurePreset": "msvc-release",
"configuration": "Release"
}
]
}
vcpkg.json:
{
"name": "quotecli",
"version-string": "0.1.0",
"builtin-baseline": "cd61e1e26a038e82d6550a3ebbe0fbbfe7da78e3",
"dependencies": [
"nlohmann-json",
"fmt",
"rang"
]
}
You can run this walkthrough with plain GitHub Copilot CLI and no extra skill installed. But if you want to guide and control Copilot’s usage of vcpkg further, especially for more advanced work, you can try out this custom vcpkg skill I wrote. See About agent skills for more information on how to integrate a skill into your Copilot workflow. This skill provides additional guidance for Copilot when dealing with custom registries, continuous integration workflows, and vcpkg troubleshooting issues. Let us know if you have any feedback in the comments below!
For more public examples of community-maintained skills and other custom Copilot workflows, see the awesome-copilot repo.
Note: Treat community skills as guidance accelerators, not official Microsoft product features with a formal support bar. You are also welcome to edit and customize them to your liking within your environment. Copilot CLI can help you write or modify skills on demand.
Resources:
I also recommend checking out Sinem Akinci’s Pure Virtual C++ session during the event, C++ semantic awareness in the CLI: From Project Load to Code Change, which goes more in-depth into Copilot CLI’s C++ capabilities with the Microsoft C++ language server.
Let us know what you think about Copilot CLI and vcpkg in the comments below!
The post C++ Dependencies Without the Headache: vcpkg + Copilot CLI appeared first on C++ Team Blog.