I’ve always been interested in finding simple ways to bring more depth into web interfaces, not just through visuals, but through interaction and space.
In this demo, I explored how flat UI cards can become interactive 3D scenes using GLB models, Three.js, and Webflow. Each card starts as a basic layout but reveals a small, self-contained environment built with real-time rendering and subtle motion.
It’s a lightweight approach to adding spatial storytelling to familiar components, using tools many designers already work with.
Welcome to My Creative World
I’m always drawn to visuals that mix the futuristic with the familiar — space-inspired forms, minimal layouts, and everyday elements seen from a different angle.
Most of my projects start this way: by reimagining ordinary ideas through a more immersive or atmospheric lens.
It All Started with a Moodboard
This one began with a simple inspiration board:
From that board, I picked a few of my favorite visuals and ran them through an AI tool that converts images into GLB 3D models.
The results were surprisingly good! Abstract, textured, and full of character.
The Concept: Flat to Deep
When I saw the output from the AI-generated GLB models, I started thinking about how we perceive depth in UI design, not just visually, but interactively.
That led to a simple idea: what if flat cards could reveal a hidden spatial layer? Not through animation alone, but through actual 3D geometry, lighting, and camera movement.
I designed three UI cards, each styled with minimal HTML and CSS in Webflow. On interaction, they load a unique GLB model into a Three.js scene directly within the card container. Each model is lit, framed, and animated to create the feeling of a self-contained 3D space.
Building the Web Experience
The layout was built in Webflow using a simple flexbox structure with three cards inside a wrapper. Each card contains a div that serves as the mounting point for a 3D object.
The GLB models are rendered using Three.js, which is integrated into the project with custom JavaScript. Each scene is initialized and handled separately, giving each card its own interactive 3D space while keeping the layout lightweight and modular.
Scene Design with Blender
Each GLB model was prepared in Blender, where I added a surrounding sphere to create a sense of depth and atmosphere. This simple shape helps simulate background contrast and encloses the object in a self-contained space.
Lighting played an important role; especially with reflective materials like glass or metal. Highlights and soft shadows were used to create that subtle, futuristic glow.
The result is that each 3D model feels like it lives inside its own ambient environment, even when rendered in a small card.
Bringing It Together with Three.js
Once the models were exported from Blender as .glb files, I used Three.js to render them inside each card. Each card container acts as its own 3D scene, initialized through a custom JavaScript function.
The setup involves creating a basic scene with a perspective camera, ambient and directional lighting, and a WebGL renderer. I used GLTFLoader to load each .glb file and OrbitControls to enable subtle rotation. Zooming and panning are disabled to keep the interaction focused and controlled.
Each model is loaded into a separate container, making it modular and easy to manage. The camera is offset slightly for a more dynamic starting view, and the background is kept dark to help the lighting pop.
Here’s the full JavaScript used to load and render the models:
// Import required libraries
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import gsap from 'gsap';
/**
* This function initializes a Three.js scene inside a given container
* and loads a .glb model into it.
*/
function createScene(containerSelector, glbPath) {
const container = document.querySelector(containerSelector);
// 1. Create a scene
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x202020); // dark background
// 2. Set up the camera with perspective
const camera = new THREE.PerspectiveCamera(
45, // Field of view
container.clientWidth / container.clientHeight, // Aspect ratio
0.1, // Near clipping plane
100 // Far clipping plane
);
camera.position.set(2, 0, 0); // Offset to the side for better viewing
// 3. Create a renderer and append it to the container
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(container.clientWidth, container.clientHeight);
container.appendChild(renderer.domElement);
// 4. Add lighting
const light = new THREE.DirectionalLight(0xffffff, 4);
light.position.set(30, -10, 20);
scene.add(light);
const ambientLight = new THREE.AmbientLight(0x404040); // soft light
scene.add(ambientLight);
// 5. Set up OrbitControls to allow rotation
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableZoom = false; // no zooming
controls.enablePan = false; // no dragging
controls.minPolarAngle = Math.PI / 2; // lock vertical angle
controls.maxPolarAngle = Math.PI / 2;
controls.enableDamping = true; // smooth movement
// 6. Load the GLB model
const loader = new GLTFLoader();
loader.load(
glbPath,
(gltf) => {
scene.add(gltf.scene); // Add model to the scene
},
(xhr) => {
console.log(`${containerSelector}: ${(xhr.loaded / xhr.total) * 100}% loaded`);
},
(error) => {
console.error(`Error loading ${glbPath}`, error);
}
);
// 7. Make it responsive
window.addEventListener("resize", () => {
camera.aspect = container.clientWidth / container.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(container.clientWidth, container.clientHeight);
});
// 8. Animate the scene
function animate() {
requestAnimationFrame(animate);
controls.update(); // updates rotation smoothly
renderer.render(scene, camera);
}
animate(); // start the animation loop
}
// 9. Initialize scenes for each card (replace with your URLs)
createScene(".div", "https://yourdomain.com/models/yourmodel.glb");
createScene(".div2", "https://yourdomain.com/models/yourmodel2.glb");
createScene(".div3", "https://yourdomain.com/models/yourmodel3.glb");
This script is added via a <script type="module"> tag, either in the Webflow page settings or as an embedded code block. Each call to createScene() initializes a new card, linking it to its corresponding .glb file.
How This Works in Practice
In Webflow, create three containers with the classes .div, .div2, and .div3. Each one will act as a canvas for a different 3D scene.
Embed the JavaScript module shown above by placing it just before the closing </body> tag in your Webflow project, or by using an Embed block with <script type="module">.
Once the page loads, each container initializes its own Three.js scene and loads the corresponding GLB model. The result: flat UI cards become interactive, scrollable 3D objects — all directly inside Webflow.
This approach is lightweight, clean, and performance-conscious, while still giving you the flexibility to work with real 3D content.
Important Note for Webflow Users
This setup works in Webflow, but only if you structure it correctly.
To make it work, you’ll need to:
Host your Three.js code externally using a bundler like Vite, Parcel, or Webpack
Or bundle the JavaScript manually and embed it as a <script type="module"> in your exported site
Keep in mind: Webflow’s Designer does not support ES module imports (import) directly. Pasting the code into an Embed block won’t work unless it’s already built and hosted elsewhere.
You’ll need to export your Webflow project or host the script externally, then link it via your project settings.
Final Thoughts
Thanks for following along with this project. What started as a simple moodboard turned into a small experiment in mixing UI design with real-time 3D.
Taking flat cards and turning them into interactive scenes was a fun way to explore how much depth you can add with just a few tools: Webflow, Three.js, and GLB models.
If this gave you an idea or made you want to try something similar, that’s what matters most. Keep experimenting, keep learning, and keep building.
In this post, I will explain how to create a pdf file in php. To create a PDF file in PHP we will use the FPDF library. It is a PHP library that is used to generate a PDF. FPDF is an open-source library. It is the best server-side PDF generation PHP library. It has rich features right from adding a PDF page to creating grids and more.
Example:
<?Php
require('fpdf/fpdf.php');
$pdf = new FPDF();
$pdf->AddPage();
$pdf->SetFont('Arial','B',16);
$pdf->Cell(80,10,'Hello World From FPDF!');
$pdf->Output('test.pdf','I'); // Send to browser and display
?>
WireMock.NET is a popular library used to simulate network communication through HTTP. But there is no simple way to integrate the generated in-memory server with an instance of IHttpClientFactory injected via constructor. Right? Wrong!
Table of Contents
Just a second! 🫷 If you are here, it means that you are a software developer.
So, you know that storage, networking, and domain management have a cost .
If you want to support this blog, please ensure that you have disabled the adblocker for this site. I configured Google AdSense to show as few ADS as possible – I don’t want to bother you with lots of ads, but I still need to add some to pay for the resources for my site.
Thank you for your understanding. – Davide
Testing the integration with external HTTP clients can be a cumbersome task, but most of the time, it is necessary to ensure that a method is able to perform correct operations – not only sending the right information but also ensuring that we are able to read the content returned from the called API.
Instead of spinning up a real server (even if in the local environment), we can simulate a connection to a mock server. A good library for creating temporary in-memory servers is WireMock.NET.
Many articles I read online focus on creating a simple HttpClient, using WireMock.NET to drive its behaviour. In this article, we are going to do a little step further: we are going to use WireMock.NET to handle HttpClients generated, using Moq, via IHttpClientFactory.
Explaining the dummy class used for the examples
As per every practical article, we must start with a dummy example.
For the sake of this article, I’ve created a dummy class with a single method that calls an external API to retrieve details of a book and then reads the returned content. If the call is successful, the method returns an instance of Book; otherwise, it throws a BookServiceException exception.
Just for completeness, here’s the Book class:
publicclassBook{
publicint Id { get; set; }
publicstring Title { get; set; }
}
publicclassBookService{
privatereadonly IHttpClientFactory _httpClientFactory;
public BookService(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
}
publicasync Task<Book> GetBookById(int id)
{
string url = $"/api/books/{id}";
HttpClient httpClient = _httpClientFactory.CreateClient("books_client");
try {
Book? book = await httpClient.GetFromJsonAsync<Book>(url);
return book;
}
catch (Exception ex)
{
thrownew BookServiceException($"There was an error while getting info about the book {id}", ex);
}
}
}
There are just two things to notice:
We are injecting an instance of IHttpClientFactory into the constructor.
We are generating an instance of HttpClient by passing a name to the CreateClient method of IHttpClientFactory.
Now that we have our cards on the table, we can start!
WireMock.NET, a library to simulate HTTP calls
WireMock is an open-source platform you can install locally to create a real mock server. You can even create a cloud environment to generate and test HTTP endpoints.
However, for this article we are interested in the NuGet package that takes inspiration from the WireMock project, allowing .NET developers to generate disposable in-memory servers: WireMock.NET.
To add the library, you must add the WireMock.NET NuGet package to your project, for example using dotnet add package WireMock.Net.
Once the package is ready, you can generate a test server in your Unit Tests class:
You can instantiate a new instance of WireMockServer in the OneTimeSetUp step, store it in a private field, and make it accessible to every test in the test class.
Before each test run, you can reset the internal status of the mock server by running the Reset() method. I’d suggest you reset the server to avoid unintentional internal status, but it all depends on what you want to do with the server instance.
Finally, remember to free up resources by calling the Stop() method in the OneTimeTearDown phase (but not during the TearDown phase: you still need the server to be on while running your tests!).
Basic configuration of HTTP requests and responses with WireMock.NET
The basic structure of the definition of a mock response using WireMock.NET is made of two parts:
Within the Given method, you define the HTTP Verb and URL path whose response is going to be mocked.
Using RespondWith you define what the mock server must return when the endpoint specified in the Given step is called.
In the next example, you can see that the _server instance (the one I instantiated in the OneTimeSetUp phase, remember?) must return a specific body (responseBody) and the 200 HTTP Status Code when the /api/books/42 endpoint is called.
All in all, both the request and the response are highly customizable: you can add HTTP Headers, delays, cookies, and much more.
Look closely; there’s one part that is missing: What is the full URL? We have declared only the path (/api/books/42) but have no info about the hostname and the port used to communicate.
How to integrate WireMock.NET with a Moq-driven IHttpClientFactory
In order to have WireMock.NET react to an HTTP call, we have to call the exact URL – even the hostname and port must match. But when we create a mocked HttpClient – like we did in this article – we don’t have a real hostname. So, how can we have WireMock.NET and HttpClient work together?
The answer is easy: since WireMockServer.Start() automatically picks a free port in your localhost, you don’t have to guess the port number, but you can reference the current instance of _server.
Once the WireMockServer is created, internally it contains the reference to one or more URLs it will use to listen for HTTP requests, intercepting the calls and replying in place of a real server. You can then use one of these ports to configure the HttpClient generated by the HttpClientFactory.
Let’s see the code:
[Test]publicasync Task GetBookById_Should_HandleBadRequests()
{
string baseUrl = _server.Url;
HttpClient myHttpClient = new HttpClient() { BaseAddress = new Uri(baseUrl) };
Mock<IHttpClientFactory> mockFactory = new Mock<IHttpClientFactory>();
mockFactory.Setup(_ => _.CreateClient("books_client")).Returns(myHttpClient);
_server
.Given(Request.Create().WithPath("/api/books/42").UsingGet())
.RespondWith(
Response.Create()
.WithStatusCode(404)
);
BookService service = new BookService(mockFactory.Object);
Assert.CatchAsync<BookServiceException>(() => service.GetBookById(42));
}
First we access the base URL used by the mock server by accessing _server.Url.
We use that URL as a base address for the newly created instance of HttpClient.
Then, we create a mock of IHttpClientFactory and configure it to return the local instance of HttpClient whenever we call the CreateClient method with the specified name.
In the meanwhile, we define how the mock server must behave when an HTTP call to the specified path is intercepted.
Finally, we can pass the instance of the mock IHttpClientFactory to the BookService.
So, the key part to remember is that you can simply access the Url property (or, if you have configured it to handle many URLs, you can access the Urls property, that is an array of strings).
Let WireMock.NET create the HttpClient for you
As suggested by Stef in the comments to this post, there’s actually another way to generate the HttpClient with the correct URL: let WireMock.NET do it for you.
Instead of doing
string baseUrl = _server.Url;
HttpClient myHttpClient = new HttpClient() { BaseAddress = new Uri(baseUrl) };
you can simplify the process by calling the CreateClient method:
HttpClient myHttpClient = _server.CreateClient();
Of course, you will still have to pass the instance to the mock of IHttpClientFactory.
Further readings
It’s important to notice that WireMock and WireMock.NET are two totally distinct things: one is a platform, and one is a library, owned by a different group of people, that mimics some functionalities from the platform to help developers write better tests.
WireMock.NET is greatly integrated with many other libraries, such as xUnit, FluentAssertions, and .NET Aspire.
It’s important to remember that using an HttpClientFactory is generally more performant than instantiating a new HttpClient. Ever heard of socket exhaustion?
Finally, for the sake of this article I’ve used Moq. However, there’s a similar library you can use: NSubstitute. The learning curve is quite flat: in the most common scenarios, it’s just a matter of syntax usage.
In this article, we almost skipped all the basic stuff about WireMock.NET and tried to go straight to the point of integrating WireMock.NET with IHttpClientFactory.
There are lots of articles out there that explain how to use WireMock.NET – just remember that WireMock and WireMock.NET are not the same thing!
I hope you enjoyed this article! Let’s keep in touch on LinkedIn or Twitter! 🤜🤛
We assume that by now you’ve all read the wonderful news about GSAP now becoming 100% free, for everyone. Thanks to Webflow’s support, all of the previously paid plugins in GSAP are now accessible to everyone. That’s why today, Osmo, Codrops and GSAP are teaming up to bring you 5 demos, available both as a Webflow cloneable and CodePen. We hope these will provide a fun intro to some cool plugins and spark a few ideas!
What you’ll learn:
SplitText basics: Break text into lines, words, or letters—with the new automatic resizing and built-in masking options!
DrawSVG scribbles: Add a playful, randomized underline to links (or anything) on hover using DrawSVG.
Physics2D text smash: Combine SplitText + Physics2D so your headline shatters into letters that tumble off the top of the viewport like a roof.
Inertia dot grid: Create an interactive, glowing dot matrix that springs and flows with your cursor for a dynamic background effect.
MorphSVG toggle: Build a seamless play/pause button that morphs one SVG into another in a single tween.
Before we dive in, let’s make sure you have the GSAP core included in your project. I will let you know the exact plugins you need per demo! You can use the official GSAP Install Helper if you need the correct npm commands or CDN links. If you’re following this as a Webflow user and you want to build from scratch, Webflow has made it super easy to integrate GSAP into your project. If you want, you can read more here. When using this approach, just make sure to add your custom code somewhere in the before </body> section of the page or project settings.
Perfect, with that set, let’s start building an interactive SplitText demo!
Interactive SplitText Demo
Before we dive into code, a couple notes:
Plugins needed: GSAP core, SplitText, and (optionally) CustomEase.
The CustomEase plugin isn’t required—feel free to swap in any ease or omit it entirely—but we’ll use it here to give our animation a distinctive feel.
Demo purpose: We’re building an interactive demo here, with buttons to trigger different reveal styles. If you just want a one-off split-text reveal (e.g. on scroll or on load), you can skip the buttons and wire your tween directly into ScrollTrigger, Click handlers, etc.
HTML and CSS Setup
<div class="text-demo-wrap">
<h1 data-split="heading" class="text-demo-h">
We’re using GSAP’s SplitText to break this content into lines, words, and individual characters. Experiment with staggered tweens, custom ease functions, and dynamic transforms to bring your headlines to life.
</h1>
<div class="text-demo-buttons">
<button data-split="button" data-split-type="lines" class="text-demo-button"><span>Lines</span></button>
<button data-split="button" data-split-type="words" class="text-demo-button"><span>Words</span></button>
<button data-split="button" data-split-type="letters" class="text-demo-button"><span>Letters</span></button>
</div>
</div>
This single call does the heavy lifting: it splits your <h1> into three levels of granularity, wraps each line in a masked container, and keeps everything in sync on resize.
const heading = document.querySelector('[data-split="heading"]');
SplitText.create(heading, {
type: "lines, words, chars", // split by lines, words & characters
mask: "lines", // optional: wraps each line in an overflow-clip <div> for a mask effect later
linesClass: "line",
wordsClass: "word",
charsClass: "letter"
});
mask: "lines" wraps each line in its own container so you can do masked reveals without extra markup.
3. Hook up the buttons
Since this is a showcase, we’ve added three buttons. One each for “Lines”, “Words” and “Letters”—to let users trigger each style on demand. In a real project you might fire these tweens on scroll, on page load, or when another interaction occurs.
To keep our code a bit cleaner, we define a config object that maps each split type to its ideal duration and stagger. Because lines, words, and letters have vastly different counts, matching your timing to the number of elements ensures each animation feels tight and responsive.
If you used the same stagger for letters as you do for lines, animating dozens (or hundreds) of chars would take forever. Tailoring the stagger to the element count keeps the reveal snappy.
function animate(type) {
// 1) Clean up any running tween so clicks “restart” cleanly
if (currentTween) {
currentTween.kill();
gsap.set(currentTargets, { yPercent: 0 });
}
// 2) Pull the right timing from our config
const { duration, stagger } = config[type];
// 3) Match the button’s data-split-type to the CSS class
// Our SplitText call used linesClass="line", wordsClass="word", charsClass="letter"
const selector = type === "lines" ? ".line"
: type === "words" ? ".word"
: ".letter";
// 4) Query the correct elements and animate
currentTargets = heading.querySelectorAll(selector);
currentTween = gsap.fromTo(
currentTargets,
{ yPercent: 110 },
{ yPercent: 0, duration, stagger, ease: "osmo-ease" }
);
}
Notice how type (the button’s data-split-type) directly aligns with our config keys and the class names we set on each slice. This tidy mapping means you can add new types (or swap class names) without rewriting your logic—just update config (and your SplitText options) and the function auto-adapts.
Finally, tie it all together with event listeners:
Let’s put all of our JS together in one neat function, and call it as soon as our fonts are loaded. This way we avoid splitting text while a fallback font is visible, and with that, we avoid any unexpected line breaks.
Give it a spin yourself! Find this demo on CodePen and grab the Webflow cloneable below. For a deep dive into every available option, check out the official SplitText docs, and head over to the CustomEase documentation to learn how to craft your own easing curves.
We’ll continue next with the Physics2D Text Smash demo—combining SplitText with another GSAP plugin for a totally different effect.
Physics2D Text Smash Demo
If you weren’t aware already, with the recent Webflow × GSAP announcements, SplitText received a major overhaul—packed with powerful new options, accessibility improvements, and a dramatically smaller bundle size. Check out the SplitText docs for all the details.
Unlike our previous demo (which was more of an interactive playground with buttons), this effect is a lot closer to a real-world application; as you scroll, each heading “breaks” into characters and falls off of your viewport like it’s hit a roof—thanks to ScrollTrigger and Physics2DPlugin.
Before we dive into code, a couple notes:
Plugins needed: GSAP core, SplitText, ScrollTrigger, and Physics2DPlugin.
Assets used: We’re using some squiggly, fun, 3D objects from a free pack on wannathis.one. Definitely check out their stuff, they have more fun things!
Demo purpose: We’re combining SplitText + Physics2D on scroll so your headings shatter into characters and “fall” off the top of the viewport, as if they hit a ‘roof’.
HTML & CSS Setup
<div class="drop-wrapper">
<div class="drop-section">
<h1 data-drop-text="" class="drop-heading">
This is just a
<span data-drop-img="" class="drop-heading-img is--first"><img loading="lazy" src="https://cdn.prod.website-files.com/681a615bf5a0f1ba3cb1ca38/681a62d0bb34b74d3514ecab_shape-squigle-1.png" alt=""></span>
random quote
<span data-drop-img="" class="drop-heading-img is--second"><img loading="lazy" src="https://cdn.prod.website-files.com/681a615bf5a0f1ba3cb1ca38/681a62d0bb34b74d3514ecad_shape-squigle-2.png" alt=""></span>
we used
</h1>
</div>
<div class="drop-section">
<h1 data-drop-text="" class="drop-heading">
See how our window acts like
<span data-drop-img="" class="drop-heading-img is--third"><img loading="lazy" src="https://cdn.prod.website-files.com/681a615bf5a0f1ba3cb1ca38/681a62d0bb34b74d3514ecaf_shape-squigle-3.png" alt=""></span>
a roof?
</h1>
</div>
<div class="drop-section">
<h1 data-drop-text="" class="drop-heading">So much fun!</h1>
</div>
</div>
We’re using aria: true here to automatically add an aria-label on the wrapper and hide split spans from screen readers. Since the latest update, aria: true is the default, so you don’t necessarily have to add it here—but we’re highlighting it for the article.
We split the text as soon as the code runs, so that we can attach a callback to the new onSplit function, but more on that in step 3.
new SplitText("[data-drop-text]", {
type: "lines, chars",
autoSplit: true, // re-split if the element resizes and it's split by lines
aria: true, // default now, but worth highlighting!
linesClass: "line",
});
With the recent SplitText update, there’s also a new option called autoSplit—which takes care of resize events, and re-splitting your text.
An important caveat for the autoSplit option; you should always create your animations in the (also new!) onSplit() callback so that if your text re-splits (when the container resizes or a font loads in), the resulting animations affect the freshly-created line/word/character elements instead of the ones from the previous split. If you’re planning on using a non-responsive font-size or just want to learn more about this (awesome) new feature that takes care of responsive line splitting, check out the documentation here.
3. Trigger on scroll
In our onSplit callback, we loop over each line in the heading, inside of a context. This context, which we return at the end, makes sure GSAP can clean up this animation whenever the text re-splits.
In our loop, we create a ScrollTrigger for each line, and we set once: true, so our animation only fires once. In step 4 we’ll add our animation!
It’s worth playing around with the start values to really nail the moment where your text visually ‘touches’ the top of the window. For our font, size, and line-height combo, an offset of 10px worked great.
new SplitText("[data-drop-text]", {
type: "lines, chars",
autoSplit: true,
aria: true,
linesClass: "line",
onSplit(self) {
// use a context to collect up all the animations
let ctx = gsap.context(() => {
self.lines.forEach((line) => { // loop around the lines
gsap.timeline({
scrollTrigger: {
once: true, // only fire once
trigger: line, // use the line as a trigger
start: "top top-=10" // adjust the trigger point to your liking
}
})
});
});
return ctx; // return our animations so GSAP can clean them up when onSplit fires
}
});
4. Drop the letters with Physics2D
Now, let’s add 2 tweens to our timeline. The first one, using the Physics2D plugin, sends each child element of the line, flying straight down with randomized velocity, angle, and gravity. A second tween makes sure the elements are faded out towards the end.
new SplitText("[data-drop-text]", {
type: "lines, chars",
autoSplit: true,
aria: true,
linesClass: "line",
onSplit(self) {
// use a context to collect up all the animations
let ctx = gsap.context(() => {
self.lines.forEach((line) => { // loop around the lines
gsap.timeline({
scrollTrigger: {
once: true, // only fire once
trigger: line, // use the line as a trigger
start: "top top-=10" // adjust the trigger point to your liking
}
})
.to(line.children, { // target the children
duration: "random(1.5, 3)", // Use randomized values for a more dynamic animation
physics2D: {
velocity: "random(500, 1000)",
angle: 90,
gravity: 3000
},
rotation: "random(-90, 90)",
ease: "none"
})
.to(line.children,{ // Start fading them out
autoAlpha: 0,
duration: 0.2
}, "-=.2");
});
});
return ctx; // return our animations so GSAP can clean them up when onSplit fires
}
});
Tip: use gsap.utils.random()! Giving each char and image a slightly different speed and spin creates a joyful, and more natural feeling to it all.
5. Putting it all together
gsap.registerPlugin(ScrollTrigger, SplitText, Physics2DPlugin);
function initDroppingText() {
new SplitText("[data-drop-text]", {
type: "lines, chars",
autoSplit: true,
aria: true,
linesClass: "line",
onSplit(self) {
// use a context to collect up all the animations
let ctx = gsap.context(() => {
self.lines.forEach((line) => {
gsap
.timeline({
scrollTrigger: {
once: true,
trigger: line,
start: "top top-=10"
}
})
.to(line.children, { // target the children
duration: "random(1.5, 3)", // Use randomized values for a more dynamic animation
physics2D: {
velocity: "random(500, 1000)",
angle: 90,
gravity: 3000
},
rotation: "random(-90, 90)",
ease: "none"
})
.to(
line.children,
{
autoAlpha: 0,
duration: 0.2
},
"-=.2"
);
});
});
return ctx; // return our animations so GSAP can clean them up when onSplit fires
}
});
}
document.addEventListener("DOMContentLoaded", initDroppingText);
Next up: an interactive Inertia Dot Grid that springs and flows with your cursor!
Glowing Interactive Dot Grid
InertiaPlugin (formerly ThrowPropsPlugin) allows you to smoothly glide any property to a stop, honoring an initial velocity as well as applying optional restrictions on the end value. It brings real-world momentum to your elements, letting them move with an initial velocity and smoothly slow under configurable resistance. You simply specify a starting velocity and resistance value, and the plugin handles the physics.
In this demo, we’re using a quick-to-prototype grid of <div> dots that glow as your cursor approaches, spring away on rapid mouse movements, and ripple outward on clicks. While a Canvas or WebGL approach would scale more efficiently for thousands of particles and deliver higher frame-rates, our div-based solution keeps the code simple and accessible—perfect for spotlighting InertiaPlugin’s capabilities.
Before we dive in:
Plugins needed: GSAP core and InertiaPlugin.
Demo purpose: Build a responsive grid of dots that glow with proximity and spring away on fast mouse moves or clicks—showcasing how the InertiaPlugin can add playful, physics-based reactions to a layout.
First, wrap everything in an initGlowingInteractiveDotsGrid() function and declare your tweakable parameters—colors, glow distance, speed thresholds, shockwave settings, max pointer speed, and whether to carve out a center hole for a logo. We also set up two arrays, dots and dotCenters, to track the elements and their positions.
With those in place, buildGrid() figures out how many columns and rows fit based on your container’s em sizing, then optionally carves out a perfectly centered block of 4 or 5 columns/rows (depending on whether the grid dimensions are even or odd) if centerHole is true. That hole gives space for your logo; set centerHole = false to fill every cell.
Inside buildGrid(), we:
Clear out any existing dots and reset our arrays.
Read the container’s fontSize to get dotPx (in px) and derive gapPx.
Calculate how many columns and rows fit, plus the total cells.
Compute a centered “hole” of 4 or 5 columns/rows if centerHole is true, so you can place a logo or focal element.
Now loop over every cell index. Inside that loop, we hide any dot in the hole region and initialize the visible ones with GSAP’s set(). Each dot is appended to the container and pushed into our dots array for tracking.
For each dot:
If it falls in the hole region, we hide it.
Otherwise, we position it at { x: 0, y: 0 } with the base color and mark it as not yet sprung.
Append it to the container and track it in dots.
// ... add this to the buildGrid() function
for (let i = 0; i < total; i++) {
const row = Math.floor(i / cols);
const col = i % cols;
const isHole =
centerHole &&
row >= startRow &&
row < startRow + holeRows &&
col >= startCol &&
col < startCol + holeCols;
const d = document.createElement("div");
d.classList.add("dot");
if (isHole) {
d.style.visibility = "hidden";
d._isHole = true;
} else {
gsap.set(d, { x: 0, y: 0, backgroundColor: colors.base });
d._inertiaApplied = false;
}
container.appendChild(d);
dots.push(d);
}
// ... more code added below
Finally, once the DOM is updated, measure each visible dot’s center coordinate—including any scroll offset—so we can calculate distances later. Wrapping in requestAnimationFrame ensures the layout is settled.
// ... add this to the buildGrid() function
requestAnimationFrame(() => {
dotCenters = dots
.filter(d => !d._isHole)
.map(d => {
const r = d.getBoundingClientRect();
return {
el: d,
x: r.left + window.scrollX + r.width / 2,
y: r.top + window.scrollY + r.height / 2
};
});
});
// this is the end of the buildGrid() function
By now, the complete buildGrid() function will look like the following:
As the user moves their cursor, we calculate its velocity by comparing the current e.pageX/e.pageY to the last recorded position over time (dt). We clamp that speed to maxSpeed to avoid runaway values. Then, on the next animation frame, we loop through each dot’s center:
Compute its distance to the cursor and derive t = Math.max(0, 1 - dist / threshold).
Interpolate its color from colors.base to colors.active.
If speed > speedThreshold and the dot is within threshold, mark it _inertiaApplied and fire an inertia tween to push it away before it springs back.
All this still goes inside of our initGlowingInteractiveDotsGrid() function:
let lastTime = 0
let lastX = 0
let lastY = 0
window.addEventListener("mousemove", e => {
const now = performance.now()
const dt = now - lastTime || 16
let dx = e.pageX - lastX
let dy = e.pageY - lastY
let vx = (dx / dt) * 1000
let vy = (dy / dt) * 1000
let speed = Math.hypot(vx, vy)
if (speed > maxSpeed) {
const scale = maxSpeed / speed
vx = vx * scale
vy = vy * scale
speed = maxSpeed
}
lastTime = now
lastX = e.pageX
lastY = e.pageY
requestAnimationFrame(() => {
dotCenters.forEach(({ el, x, y }) => {
const dist = Math.hypot(x - e.pageX, y - e.pageY)
const t = Math.max(0, 1 - dist / threshold)
const col = gsap.utils.interpolate(colors.base, colors.active, t)
gsap.set(el, { backgroundColor: col })
if (speed > speedThreshold && dist < threshold && !el._inertiaApplied) {
el._inertiaApplied = true
const pushX = (x - e.pageX) + vx * 0.005
const pushY = (y - e.pageY) + vy * 0.005
gsap.to(el, {
inertia: { x: pushX, y: pushY, resistance: 750 },
onComplete() {
gsap.to(el, {
x: 0,
y: 0,
duration: 1.5,
ease: "elastic.out(1, 0.75)"
})
el._inertiaApplied = false
}
})
}
})
})
})
4. Handle click ‘shockwave’ effect
On each click, we send a radial ‘shockwave’ through the grid. We reuse the same inertia + elastic return logic, but scale the push by a distance-based falloff so that dots closer to the click move further, then all spring back in unison.
Next up: DrawSVG Scribbles Demo — let’s draw some playful, randomized underlines on hover!
DrawSVG Scribbles Demo
GSAP’s DrawSVGPlugin animates the stroke of an SVG path by tweening its stroke-dasharray and stroke-dashoffset, creating a ‘drawing’ effect. You can control start/end percentages, duration, easing, and even stagger multiple paths. In this demo, we’ll attach a randomized scribble underline to each link on hover—perfect for adding a playful touch to your navigation or call-to-actions.
We define an array of exact SVG scribbles. Each string is a standalone <svg> with its <path>. When we inject it, we run decorateSVG() to ensure it scales to its container and uses currentColor for theming.
We’ve drawn these scribbles ourselves in figma using the pencil. We recommend drawing (and thus creating the path coordinates) in the order of which you want to draw them.
Wrap the above setup in your initDrawRandomUnderline() function and call it once the DOM is ready:
function initDrawRandomUnderline() {
// svgVariants, decorateSVG, and all event listeners…
}
document.addEventListener('DOMContentLoaded', initDrawRandomUnderline);
And now on to the final demo: MorphSVG Toggle Demo—see how to morph one icon into another in a single tween!
MorphSVG Toggle Demo
MorphSVGPlugin lets you fluidly morph one SVG shape into another—even when they have different numbers of points—by intelligently mapping anchor points. You can choose the morphing algorithm (size, position or complexity), control easing, duration, and even add rotation to make the transition feel extra smooth. In this demo, we’re toggling between a play ► and pause ❚❚ icon on button click, then flipping back. Perfect for video players, music apps, or any interactive control.
We highly recommend diving into the docs for this plugin, as there are a whole bunch of options and possibilities.
Plugins needed: GSAP core and MorphSVGPlugin
Demo purpose: Build a play/pause button that seamlessly morphs its SVG path on each click.
We store two path definitions: playPath and pausePath, then grab our button and the <path> element inside it. A simple isPlaying boolean tracks state. On each click, we call gsap.to() on the SVG path, passing morphSVG options:
type: “rotational” to smoothly rotate points into place
map: “complexity” to match by number of anchors for speed
shape set to the opposite icon’s path
Finally, we flip isPlaying so the next click morphs back.
Thank you for making it this far down the page! We know it’s a rather long read, so we hope there’s some inspiring stuff in here for you. Both Dennis and I are super stoked with all the GSAP Plugins being free now, and can’t wait to create more resources with them.
As a note, we’re fully aware that all the HTML and markup in the article is rather concise, and definitely not up to standard with all best practices for accessibility. To make these resources production-ready, definitely look for guidance on the standards at w3.org! Think of the above ones as your launch-pad. Ready to tweak and make your own.
Have a lovely rest of your day, or night, wherever you are. Happy animating!
Access a growing library of resources
Built by two award-winning creative developers Dennis Snellenberg and Ilja van Eck, our vault gives you access to the techniques, components, code, and tools behind our projects. All neatly packed in a custom-built dashboard. Build, tweak, and make them your own—for Webflow and non-Webflow users.
Become a member today to unlock our growing set of components and join a community of more than 850 creative developers worldwide!
Just a second! 🫷 If you are here, it means that you are a software developer.
So, you know that storage, networking, and domain management have a cost .
If you want to support this blog, please ensure that you have disabled the adblocker for this site. I configured Google AdSense to show as few ADS as possible – I don’t want to bother you with lots of ads, but I still need to add some to pay for the resources for my site.
Thank you for your understanding. – Davide
In a previous article, we delved into the creation of realistic data using Bogus, an open-source library that allows you to generate data with plausible values.
Bogus contains several properties and methods that generate realistic data such as names, addresses, birthdays, and so on.
In this article, we will learn two ways to generate data with Bogus: both ways generate the same result; the main change is on the reusability and the modularity. But, in my opinion, it’s just a matter of preference: there is no approach absolutely better than the other. However, both methods can be preferred in specific cases.
For the sake of this article, we are going to use Bogus to generate instances of the Book class, defined like this:
It is possible to create a specific object that, using a Builder approach, allows you to generate one or more items of a specified type.
It all starts with the Faker<T> generic type, where T is the type you want to generate.
Once you create it, you can define the rules to be used when initializing the properties of a Book by using methods such as RuleFor and RuleForType.
publicstaticclassBogusBookGenerator{
publicstatic Faker<Book> CreateFaker()
{
Faker<Book> bookFaker = new Faker<Book>()
.RuleFor(b => b.Id, f => f.Random.Guid())
.RuleFor(b => b.Title, f => f.Lorem.Text())
.RuleFor(b => b.Genres, f => f.Random.EnumValues<Genre>())
.RuleFor(b => b.AuthorFirstName, f => f.Person.FirstName)
.RuleFor(b => b.AuthorLastName, f => f.Person.LastName)
.RuleFor(nameof(Book.PagesCount), f => f.Random.Number(100, 800))
.RuleForType(typeof(DateOnly), f => f.Date.PastDateOnly());
return bookFaker;
}
}
In this way, thanks to the static method, you can simply create a new instance of Faker<Book>, ask it to generate one or more books, and enjoy the result:
Faker<Book> generator = BogusBookGenerator.CreateFaker();
var books = generator.Generate(10);
Clearly, it’s not necessary for the class to be marked as static: it all depends on what you need to achieve!
Expose a subtype of Faker, specific for the data type to be generated
If you don’t want to use a method (static or not static, it doesn’t matter), you can define a subtype of Faker<Book> whose customization rules are all defined in the constructor.
publicclassBookGenerator : Faker<Book>
{
public BookGenerator()
{
RuleFor(b => b.Id, f => f.Random.Guid());
RuleFor(b => b.Title, f => f.Lorem.Text());
RuleFor(b => b.Genres, f => f.Random.EnumValues<Genre>());
RuleFor(b => b.AuthorFirstName, f => f.Person.FirstName);
RuleFor(b => b.AuthorLastName, f => f.Person.LastName);
RuleFor(nameof(Book.PagesCount), f => f.Random.Number(100, 800));
RuleForType(typeof(DateOnly), f => f.Date.PastDateOnly());
}
}
Using this way, you can simply create a new instance of BookGenerator and, again, call the Generate method to create new book instances.
var generator = new BookGenerator();
var books = generator.Generate(10);
Method vs Subclass: When should we use which?
As we saw, both methods bring the same result, and their usage is almost identical.
So, which way should I use?
Use the method approach (the first one) when you need:
Simplicity: If you need to generate fake data quickly and your rules are straightforward, using a method is the easiest approach.
Ad-hoc Data Generation: Ideal for one-off or simple scenarios where you don’t need to reuse the same rules across your application.
Or use the subclass (the second approach) when you need:
Reusability: If you need to generate the same type of fake data in multiple places, defining a subclass allows you to encapsulate the rules and reuse them easily.
Complex scenarios and extensibility: Better suited for more complex data generation scenarios where you might have many rules or need to extend the functionality.
Maintainability: Easier to maintain and update the rules in one place.
Further readings
If you want to learn a bit more about Bogus and use it to populate data used by Entity Framework, I recently published an article about this topic:
I think Bogus is one of the best libraries in the .NET universe, as having realistic data can help you improve the intelligibility of the test cases you generate. Also, Bogus can be a great tool when you want to showcase demo values without accessing real data.
I hope you enjoyed this article! Let’s keep in touch on LinkedIn, Twitter or BlueSky! 🤜🤛
Many of you will know that every man and his dog are producing AI products or LLM’s and integrating them with their products. Not surprisingly AWS — the biggest cloud services provider — is also getting in on the act.
What is bedrock?
Its AI offering is called Bedrock and the following blurb from it’s website describes what Bedrock is.
Amazon Bedrock is a fully managed service that offers a choice of high-performing foundation models (FMs) from leading AI companies like AI21 Labs, Anthropic, Cohere, Meta, Stability AI, and Amazon via a single API, along with a broad set of capabilities you need to build generative AI applications, simplifying development while maintaining privacy and security. With Amazon Bedrock’s comprehensive capabilities, you can easily experiment with a variety of top FMs, privately customize them with your data using techniques such as fine-tuning and retrieval augmented generation (RAG), and create managed agents that execute complex business tasks — from booking travel and processing insurance claims to creating ad campaigns and managing inventory — all without writing any code. Since Amazon Bedrock is serverless, you don’t have to manage any infrastructure, and you can securely integrate and deploy generative AI…
Application Insights is a great tool for handling high volumes of logs. How can you configure an ASP.NET application to send logs to Azure Application Insights? What can I do to have Application Insights log my exceptions?
Table of Contents
Just a second! 🫷 If you are here, it means that you are a software developer.
So, you know that storage, networking, and domain management have a cost .
If you want to support this blog, please ensure that you have disabled the adblocker for this site. I configured Google AdSense to show as few ADS as possible – I don’t want to bother you with lots of ads, but I still need to add some to pay for the resources for my site.
Thank you for your understanding. – Davide
Logging is crucial for any application. However, generating logs is not enough: you must store them somewhere to access them.
Application Insights is one of the tools that allows you to store your logs in a cloud environment. It provides a UI and a query editor that allows you to drill down into the details of your logs.
In this article, we will learn how to integrate Azure Application Insights with an ASP.NET Core application and how Application Insights treats log properties such as Log Levels and exceptions.
For the sake of this article, I’m working on an API project with HTTP Controllers with only one endpoint. The same approach can be used for other types of applications.
How to retrieve the Azure Application Insights connection string
Azure Application Insights can be accessed via any browser by using the Azure Portal.
Once you have an instance ready, you can simply get the value of the connection string for that resource.
You can retrieve it in two ways.
You can get the connection string by looking at the Connection String property in the resource overview panel:
The alternative is to navigate to the Configure > Properties page and locate the Connection String field.
How to add Azure Application Insights to an ASP.NET Core application
Now that you have the connection string, you can place it in the configuration file or, in general, store it in a place that is accessible from your application.
To configure ASP.NET Core to use Application Insights, you must first install the Microsoft.Extensions.Logging.ApplicationInsights NuGet package.
Now you can add a new configuration to the Program class (or wherever you configure your services and the ASP.NET core pipeline):
The configureApplicationInsightsLoggerOptions allows you to configure some additional properties: TrackExceptionsAsExceptionTelemetry, IncludeScopes, and FlushOnDispose. These properties are by default set to true, so you probably don’t want to change the default behaviour (except one, which we’ll modify later).
And that’s it! You have Application Insights ready to be used.
How log levels are stored and visualized on Application Insights
I have this API endpoint that does nothing fancy: it just returns a random number.
We can use it to run experiments on how logs are treated using Application Insights.
First, let’s add some simple log messages in the Get endpoint:
[HttpGet]publicasync Task<IActionResult> Get()
{
int number = Random.Shared.Next();
_logger.LogDebug("A debug log");
_logger.LogTrace("A trace log");
_logger.LogInformation("An information log");
_logger.LogWarning("A warning log");
_logger.LogError("An error log");
_logger.LogCritical("A critical log");
return Ok(number);
}
These are just plain messages. Let’s search for them in Application Insights!
You first have to run the application – duh! – and wait for a couple of minutes for the logs to be ready on Azure. So, remember not to close the application immediately: you have to give it a few seconds to send the log messages to Application Insights.
Then, you can open the logs panel and access the logs stored in the traces table.
As you can see, the messages appear in the query result.
There are three important things to notice:
in .NET, the log level is called “Log Level”, while on Application Insights it’s called “severity level”;
the log levels lower than Information are ignored by default (in fact, you cannot see them in the query result);
the Log Levels are exposed as numbers in the severityLevel column: the higher the value, the higher the log level.
So, if you want to update the query to show only the log messages that are at least Warnings, you can do something like this:
traces
| where severityLevel >= 2
| order by timestamp desc
| project timestamp, message, severityLevel
How to log exceptions on Application Insights
In the previous example, we logged errors like this:
_logger.LogError("An error log");
Fortunately, ILogger exposes an overload that accepts an exception in input and logs all the details.
Let’s try it by throwing an exception (I chose AbandonedMutexException because it’s totally nonsense in this simple context, so it’s easy to spot).
privatevoid SomethingWithException(int number)
{
try {
_logger.LogInformation("In the Try block");
thrownew AbandonedMutexException("An exception message");
}
catch (Exception ex)
{
_logger.LogInformation("In the Catch block");
_logger.LogError(ex, "Unable to complete the operation");
}
finally {
_logger.LogInformation("In the Finally block");
}
}
So, when calling it, we expect to see 4 log entries, one of which contains the details of the AbandonedMutexException exception.
Hey, where is the exception message??
It turns out that ILogger, when creating log entries like _logger.LogError("An error log");, generates objects of type TraceTelemetry. However, the overload that accepts as a first parameter an exception (_logger.LogError(ex, "Unable to complete the operation");) is internally handled as an ExceptionTelemetry object. Since internally, it’s a different type of Telemetry object, and it gets ignored by default.
To enable logging exceptions, you have to update the way you add Application Insights to your application by setting the TrackExceptionsAsExceptionTelemetry property to false:
It’s not the first time we have written about logging in this blog.
For example, suppose you don’t want to use Application Insights but prefer an open-source, vendor-independent log sink. In that case, my suggestion is to try Seq:
This article taught us how to set up Azure Application Insights in an ASP.NET application.
We touched on the basics, discussing log levels and error handling. In future articles, we’ll delve into some other aspects of logging, such as correlating logs, understanding scopes, and more.
I hope you enjoyed this article! Let’s keep in touch on LinkedIn, Twitter or BlueSky! 🤜🤛
Home Assistant can quickly become a hobby that overwhelms you. Every object (entity) in your house that is even remotely connected can become programmable. Everything. Even people! You can declare that any name:value pair that (for example) your phone can expose can be consumable by Home Assistant. Questions like “is Scott home” or “what’s Scott’s phone battery” can be associated with Scott the Entity in the Home Assistant Dashboard.
I was amazed at the devices/objects that Home Assistant discovered that it could automate. Lights, remotes, Spotify, and more. You’ll find that any internally connected device you have likely has an Integration available.
Temperature, Light Status, sure, that’s easy Home Automation. But integrations and 3rd party code can give you details like “Is the Living Room dark” or “is there motion in the driveway.” From these building blocks, you can then build your own IFTTT (If This Then That) automations, combining not just two systems, but any and all disparate systems.
What’s the best part? This all runs LOCALLY. Not in a cloud or the cloud or anyone’s cloud. I’ve got my stuff running on a Raspberry Pi 4. Even better I put a Power Over Ethernet (PoE) hat on my Rpi so I have just one network wire into my hub that powers the Pi.
I believe setting up Home Assistant on a Pi is the best and easiest way to get started. That said, you can also run in a Docker Container, on a Synology or other NAS, or just on Windows or Mac in the background. It’s up to you. Optionally, you can pay Nabu Casa $5 for remote (outside your house) network access via transparent forwarding. But to be clear, it all still runs inside your house and not in the cloud.
OK, to the main point. I used to have an Amazon Ring Doorbell that would integrate with Amazon Alexa and when you pressed the doorbell it would say “Someone is at the front door” on our all Alexas. It was a lovely little integration that worked nicely in our lives.
However, I swapped out the Ring for a Unifi Protect G4 Doorbell for a number of reasons. I don’t want to pump video to outside services, so this doorbell integrates nicely with my existing Unifi installation and records video to a local hard drive. However, I lose any Alexa integration and this nice little “someone is at the door” announcement. So this seems like a perfect job for Home Assistant.
This enables 3rd party “untrusted” integrations directly from GitHub. You’ll need a GitHub account and it’ll clone custom integrations directly into your local HA.
I also recommend the Terminal & SSH (9.2.2), File editor (5.3.3) add ons so you can see what’s happening.
NOTE: Unifi Protect support is being promoted in Home Assistant v2022.2 so you won’t need this step soon as it’ll be included.
“The UniFi Protect Integration adds support for retrieving Camera feeds and Sensor data from a UniFi Protect installation on either an Ubiquiti CloudKey+, Ubiquiti UniFi Dream Machine Pro or UniFi Protect Network Video Recorder.”
This makes all your Alexas show up in Home Assistant as “media players” and also allows you to tts (text to speech) to them.
Authenticate and configure this integration.
I recommend going into your Alexa app and making a Multi-room Speaker Group called “everywhere.” Not only because it’s nice to be able to say “play the music everywhere” but you can also target that “Everywhere” group in Home Assistant.
service: notify.alexa_media_everywhere
data:
message: Someone is at the front door, this is a test
data:
type: announce
method: speak
If that works, you know you can automate Alexa and make it say things. Now, go to Configuration, Automation, and Add a new Automation. Here’s mine. I used the UI to create it. Note that your Entity names may be different if you give your front doorbell camera a different name.
Notice the format of Data, it’s name value pairs within a single field’s value.
…but it also exists in a file called Automations.yaml. Note that the “to: ‘on’” trigger is required or you’ll get double announcements, one for each state change in the doorbell.
- id: '1640995128073'
alias: G4 Doorbell Announcement with Alexa
description: G4 Doorbell Announcement with Alexa
trigger:
- platform: state
entity_id: binary_sensor.front_door_doorbell
to: 'on'
condition: []
action:
- service: notify.alexa_media_everywhere
data:
data:
type: announce
method: speak
message: Someone is at the front door
mode: single
It works! There’s a ton of cool stuff I can automate now!
Sponsor: Make login Auth0’s problem. Not yours. Provide the convenient login features your customers want, like social login, multi-factor authentication, single sign-on, passwordless, and more. Get started for free.
About Scott
Scott Hanselman is a former professor, former Chief Architect in finance, now speaker, consultant, father, diabetic, and Microsoft employee. He is a failed stand-up comic, a cornrower, and a book author.
There are many let’s encrypt automatic tools for azure but I also wanted to see if I could use certbot in wsl to generate a wildcard certificate for the azure Friday website and then upload the resulting certificates to azure app service.
Azure app service ultimately needs a specific format called dot PFX that includes the full certificate path and all intermediates.
Then I generate the cert. You’ll get a nice text UI from certbot and update your DNS as a verification challenge. Change this to make sure it’s two lines, and your domains and subdomains are correct and your paths are correct.
Then upload the cert to the Certificates section of your App Service, under Bring Your Own Cert.
Then under Custom Domains, click Update Binding and select the new cert (with the latest expiration date).
Next step is to make this even more automatic or select a more automated solution but for now, I’ll worry about this in September and it solved my expensive Wildcard Domain issue.
About Scott
Scott Hanselman is a former professor, former Chief Architect in finance, now speaker, consultant, father, diabetic, and Microsoft employee. He is a failed stand-up comic, a cornrower, and a book author.