Learn how to create an infinite marquee that follows a custom SVG path using React and Motion.
Source link
برچسب: With
-
Building an Infinite Marquee Along an SVG Path with React & Motion
-
Use TestCase to run similar unit tests with NUnit | Code4IT
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.
– DavideIn my opinion, Unit tests should be well structured and written even better than production code.
In fact, Unit Tests act as a first level of documentation of what your code does and, if written properly, can be the key to fixing bugs quickly and without adding regressions.
One way to improve readability is by grouping similar tests that only differ by the initial input but whose behaviour is the same.
Let’s use a dummy example: some tests on a simple
Calculator
class that only performs sums on int values.public static class Calculator { public static int Sum(int first, int second) => first + second; }
One way to create tests is by creating one test for each possible combination of values:
public class SumTests { [Test] public void SumPositiveNumbers() { var result = Calculator.Sum(1, 5); Assert.That(result, Is.EqualTo(6)); } [Test] public void SumNegativeNumbers() { var result = Calculator.Sum(-1, -5); Assert.That(result, Is.EqualTo(-6)); } [Test] public void SumWithZero() { var result = Calculator.Sum(1, 0); Assert.That(result, Is.EqualTo(1)); } }
However, it’s not a good idea: you’ll end up with lots of identical tests (DRY, remember?) that add little to no value to the test suite. Also, this approach forces you to add a new test method to every new kind of test that pops into your mind.
When possible, we should generalize it. With NUnit, we can use the
TestCase
attribute to specify the list of parameters passed in input to our test method, including the expected result.We can then simplify the whole test class by creating only one method that accepts the different cases in input and runs tests on those values.
[Test] [TestCase(1, 5, 6)] [TestCase(-1, -5, -6)] [TestCase(1, 0, 1)] public void SumWorksCorrectly(int first, int second, int expected) { var result = Calculator.Sum(first, second); Assert.That(result, Is.EqualTo(expected)); }
By using
TestCase
, you can cover different cases by simply adding a new case without creating new methods.Clearly, don’t abuse it: use it only to group methods with similar behaviour – and don’t add
if
statements in the test method!There is a more advanced way to create a TestCase in NUnit, named
TestCaseSource
– but we will talk about it in a future C# tip 😉Further readings
If you are using NUnit, I suggest you read this article about custom equality checks – you might find it handy in your code!
🔗 C# Tip: Use custom Equality comparers in Nunit tests | Code4IT
This article first appeared on Code4IT 🐧
Wrapping up
I hope you enjoyed this article! Let’s keep in touch on Twitter or LinkedIn! 🤜🤛
Happy coding!
🐧
-
Building an Infinite Parallax Grid with GSAP and Seamless Tiling
Hey! Jorge Toloza again, Co-Founder and Creative Director at DDS Studio. In this tutorial, we’re going to build a visually rich, infinitely scrolling grid where images move with a parallax effect based on scroll and drag interactions.
We’ll use GSAP for buttery-smooth animations, add a sprinkle of math to achieve infinite tiling, and bring it all together with dynamic visibility animations and a staggered intro reveal.
Let’s get started!
Setting Up the HTML Container
To start, we only need a single container to hold all the tiled image elements. Since we’ll be generating and positioning each tile dynamically with JavaScript, there’s no need for any static markup inside. This keeps our HTML clean and scalable as we duplicate tiles for infinite scrolling.
<div id="images"></div>
Basic Styling for the Grid Items
Now that we have our container, let’s give it the foundational styles it needs to hold and animate a large set of tiles.
We’ll use
absolute
positioning for each tile so we can freely place them anywhere in the grid. The outer container (#images
) is set torelative
so that all child.item
elements are positioned correctly inside it. Each image fills its tile, and we’ll usewill-change: transform
to optimize animation performance.#images { width: 100%; height: 100%; display: inline-block; white-space: nowrap; position: relative; .item { position: absolute; top: 0; left: 0; will-change: transform; white-space: normal; .item-wrapper { will-change: transform; } .item-image { overflow: hidden; img { width: 100%; height: 100%; object-fit: cover; will-change: transform; } } small { width: 100%; display: block; font-size: 8rem; line-height: 1.25; margin-top: 12rem; } } }
Defining Item Positions with JSON from Figma
To control the visual layout of our grid, we’ll use design data exported directly from Figma. This gives us pixel-perfect placement while keeping layout logic separate from our code.
I created a quick layout in Figma using rectangles to represent tile positions and dimensions. Then I exported that data into a JSON file, giving us a simple array of objects containing
x
,y
,w
, andh
values for each tile.[ {x: 71, y: 58, w: 400, h: 270}, {x: 211, y: 255, w: 540, h: 360}, {x: 631, y: 158, w: 400, h: 270}, {x: 1191, y: 245, w: 260, h: 195}, {x: 351, y: 687, w: 260, h: 290}, {x: 751, y: 824, w: 205, h: 154}, {x: 911, y: 540, w: 260, h: 350}, {x: 1051, y: 803, w: 400, h: 300}, {x: 71, y: 922, w: 350, h: 260}, ]
Generating an Infinite Grid with JavaScript
With the layout data defined, the next step is to dynamically generate our tile grid in the DOM and enable it to scroll infinitely in both directions.
This involves three main steps:
- Compute the scaled tile dimensions based on the viewport and the original Figma layout’s aspect ratio.
- Duplicate the grid in both the X and Y axes so that as one tile set moves out of view, another seamlessly takes its place.
- Store metadata for each tile, such as its original position and a random easing value, which we’ll use to vary the parallax animation slightly for a more organic effect.
The infinite scroll illusion is achieved by duplicating the entire tile set horizontally and vertically. This 2×2 tiling approach ensures there’s always a full set of tiles ready to slide into view as the user scrolls or drags.
onResize() { // Get current viewport dimensions this.winW = window.innerWidth; this.winH = window.innerHeight; // Scale tile size to match viewport width while keeping original aspect ratio this.tileSize = { w: this.winW, h: this.winW * (this.originalSize.h / this.originalSize.w), }; // Reset scroll state this.scroll.current = { x: 0, y: 0 }; this.scroll.target = { x: 0, y: 0 }; this.scroll.last = { x: 0, y: 0 }; // Clear existing tiles from container this.$container.innerHTML = ''; // Scale item positions and sizes based on new tile size const baseItems = this.data.map((d, i) => { const scaleX = this.tileSize.w / this.originalSize.w; const scaleY = this.tileSize.h / this.originalSize.h; const source = this.sources[i % this.sources.length]; return { src: source.src, caption: source.caption, x: d.x * scaleX, y: d.y * scaleY, w: d.w * scaleX, h: d.h * scaleY, }; }); this.items = []; // Offsets to duplicate the grid in X and Y for seamless looping (2x2 tiling) const repsX = [0, this.tileSize.w]; const repsY = [0, this.tileSize.h]; baseItems.forEach((base) => { repsX.forEach((offsetX) => { repsY.forEach((offsetY) => { // Create item DOM structure const el = document.createElement('div'); el.classList.add('item'); el.style.width = `${base.w}px`; const wrapper = document.createElement('div'); wrapper.classList.add('item-wrapper'); el.appendChild(wrapper); const itemImage = document.createElement('div'); itemImage.classList.add('item-image'); itemImage.style.width = `${base.w}px`; itemImage.style.height = `${base.h}px`; wrapper.appendChild(itemImage); const img = new Image(); img.src = `./img/${base.src}`; itemImage.appendChild(img); const caption = document.createElement('small'); caption.innerHTML = base.caption; // Split caption into lines for staggered animation const split = new SplitText(caption, { type: 'lines', mask: 'lines', linesClass: 'line' }); split.lines.forEach((line, i) => { line.style.transitionDelay = `${i * 0.15}s`; line.parentElement.style.transitionDelay = `${i * 0.15}s`; }); wrapper.appendChild(caption); this.$container.appendChild(el); // Observe caption visibility for animation triggering this.observer.observe(caption); // Store item metadata including offset, easing, and bounding box this.items.push({ el, container: itemImage, wrapper, img, x: base.x + offsetX, y: base.y + offsetY, w: base.w, h: base.h, extraX: 0, extraY: 0, rect: el.getBoundingClientRect(), ease: Math.random() * 0.5 + 0.5, // Random parallax easing for organic movement }); }); }); }); // Double the tile area to account for 2x2 duplication this.tileSize.w *= 2; this.tileSize.h *= 2; // Set initial scroll position slightly off-center for visual balance this.scroll.current.x = this.scroll.target.x = this.scroll.last.x = -this.winW * 0.1; this.scroll.current.y = this.scroll.target.y = this.scroll.last.y = -this.winH * 0.1; }
Key Concepts
- Scaling the layout ensures that your Figma-defined design adapts to any screen size without distortion.
- 2×2 duplication ensures seamless continuity when the user scrolls in any direction.
- Random easing values create slight variation in tile movement, making the parallax effect feel more natural.
extraX
andextraY
values will later be used to shift tiles back into view once they scroll offscreen.- SplitText animation is used to break each caption (
<small>
) into individual lines, enabling line-by-line animation.
Adding Interactive Scroll and Drag Events
To bring the infinite grid to life, we need to connect it to user input. This includes:
- Scrolling with the mouse wheel or trackpad
- Dragging with a pointer (mouse or touch)
- Smooth motion between input updates using linear interpolation (lerp)
Rather than instantly snapping to new positions, we interpolate between the current and target scroll values, which creates fluid, natural transitions.
Scroll and Drag Tracking
We capture two types of user interaction:
1) Wheel Events
Wheel input updates a target scroll position. We multiply the deltas by a damping factor to control sensitivity.onWheel(e) { e.preventDefault(); const factor = 0.4; this.scroll.target.x -= e.deltaX * factor; this.scroll.target.y -= e.deltaY * factor; }
2) Pointer Dragging
On mouse or touch input, we track when the drag starts, then update scroll targets based on the pointer’s movement.onMouseDown(e) { e.preventDefault(); this.isDragging = true; document.documentElement.classList.add('dragging'); this.mouse.press.t = 1; this.drag.startX = e.clientX; this.drag.startY = e.clientY; this.drag.scrollX = this.scroll.target.x; this.drag.scrollY = this.scroll.target.y; } onMouseUp() { this.isDragging = false; document.documentElement.classList.remove('dragging'); this.mouse.press.t = 0; } onMouseMove(e) { this.mouse.x.t = e.clientX / this.winW; this.mouse.y.t = e.clientY / this.winH; if (this.isDragging) { const dx = e.clientX - this.drag.startX; const dy = e.clientY - this.drag.startY; this.scroll.target.x = this.drag.scrollX + dx; this.scroll.target.y = this.drag.scrollY + dy; } }
Smoothing Motion with Lerp
In the render loop, we interpolate between the current and target scroll values using a lerp function. This creates smooth, decaying motion rather than abrupt changes.
render() { // Smooth current → target this.scroll.current.x += (this.scroll.target.x - this.scroll.current.x) * this.scroll.ease; this.scroll.current.y += (this.scroll.target.y - this.scroll.current.y) * this.scroll.ease; // Calculate delta for parallax const dx = this.scroll.current.x - this.scroll.last.x; const dy = this.scroll.current.y - this.scroll.last.y; // Update each tile this.items.forEach(item => { const parX = 5 * dx * item.ease + (this.mouse.x.c - 0.5) * item.rect.width * 0.6; const parY = 5 * dy * item.ease + (this.mouse.y.c - 0.5) * item.rect.height * 0.6; // Infinite wrapping const posX = item.x + this.scroll.current.x + item.extraX + parX; if (posX > this.winW) item.extraX -= this.tileSize.w; if (posX + item.rect.width < 0) item.extraX += this.tileSize.w; const posY = item.y + this.scroll.current.y + item.extraY + parY; if (posY > this.winH) item.extraY -= this.tileSize.h; if (posY + item.rect.height < 0) item.extraY += this.tileSize.h; item.el.style.transform = `translate(${posX}px, ${posY}px)`; }); this.scroll.last.x = this.scroll.current.x; this.scroll.last.y = this.scroll.current.y; requestAnimationFrame(this.render); }
The
scroll.ease
value controls how fast the scroll position catches up to the target—smaller values result in slower, smoother motion.Animating Item Visibility with IntersectionObserver
To enhance the visual hierarchy and focus, we’ll highlight only the tiles that are currently within the viewport. This creates a dynamic effect where captions appear and styling changes as tiles enter view.
We’ll use the IntersectionObserver API to detect when each tile becomes visible and toggle a CSS class accordingly.
this.observer = new IntersectionObserver(entries => { entries.forEach(entry => { entry.target.classList.toggle('visible', entry.isIntersecting); }); }); // …and after appending each wrapper: this.observer.observe(wrapper);
Creating an Intro Animation with GSAP
To finish the experience with a strong visual entry, we’ll animate all currently visible tiles from the center of the screen into their natural grid positions. This creates a polished, attention-grabbing introduction and adds a sense of depth and intentionality to the layout.
We’ll use GSAP for this animation, utilizing
gsap.set()
to position elements instantly, andgsap.to()
with staggered timing to animate them into place.Selecting Visible Tiles for Animation
First, we filter all tile elements to include only those currently visible in the viewport. This avoids animating offscreen elements and keeps the intro lightweight and focused:
import gsap from 'gsap'; initIntro() { this.introItems = [...this.$container.querySelectorAll('.item-wrapper')].filter((item) => { const rect = item.getBoundingClientRect(); return ( rect.x > -rect.width && rect.x < window.innerWidth + rect.width && rect.y > -rect.height && rect.y < window.innerHeight + rect.height ); }); this.introItems.forEach((item) => { const rect = item.getBoundingClientRect(); const x = -rect.x + window.innerWidth * 0.5 - rect.width * 0.5; const y = -rect.y + window.innerHeight * 0.5 - rect.height * 0.5; gsap.set(item, { x, y }); }); }
Animating to Final Positions
Once the tiles are centered, we animate them outward to their natural positions using a smooth easing curve and staggered timing:
intro() { gsap.to(this.introItems.reverse(), { duration: 2, ease: 'expo.inOut', x: 0, y: 0, stagger: 0.05, }); }
x: 0, y: 0
restores the original position set via CSS transforms.expo.inOut
provides a dramatic but smooth easing curve.stagger
creates a cascading effect, enhancing visual rhythm
Wrapping Up
What we’ve built is a scrollable, draggable image grid with a parallax effect, visibility animations, and a smooth GSAP-powered intro. It’s a flexible base you can adapt for creative galleries, interactive backgrounds, or experimental interfaces.
-
Handling exceptions with Task.WaitAll and Task.WhenAll | Code4IT
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.
– DavideAsynchronous programming enables you to execute multiple operations without blocking the main thread.
In general, we often think of the Happy Scenario, when all the operations go smoothly, but we rarely consider what to do when an error occurs.
In this article, we will explore how
Task.WaitAll
andTask.WhenAll
behave when an error is thrown in one of the awaited Tasks.Prepare the tasks to be executed
For the sake of this article, we are going to use a silly method that returns the same number passed in input but throws an exception in case the input number can be divided by 3:
public Task<int> Echo(int value) => Task.Factory.StartNew( () => { if (value % 3 == 0) { Console.WriteLine($"[LOG] You cannot use {value}!"); throw new Exception($"[EXCEPTION] Value cannot be {value}"); } Console.WriteLine($"[LOG] {value} is a valid value!"); return value; } );
Those
Console.WriteLine
instructions will allow us to see what’s happening “live”.We prepare the collection of tasks to be awaited by using a simple
Enumerable.Range
var tasks = Enumerable.Range(1, 11).Select(Echo);
And then, we use a try-catch block with some logs to showcase what happens when we run the application.
try { Console.WriteLine("START"); // await all the tasks Console.WriteLine("END"); } catch (Exception ex) { Console.WriteLine("The exception message is: {0}", ex.Message); Console.WriteLine("The exception type is: {0}", ex.GetType().FullName); if (ex.InnerException is not null) { Console.WriteLine("Inner exception: {0}", ex.InnerException.Message); } } finally { Console.WriteLine("FINALLY!"); }
If we run it all together, we can notice that nothing really happened:
In fact, we just created a collection of tasks (which does not actually exist, since the result is stored in a lazy-loaded enumeration).
We can, then, call WaitAll and WhenAll to see what happens when an error occurs.
Error handling when using Task.WaitAll
It’s time to execute the tasks stored in the
tasks
collection, like this:try { Console.WriteLine("START"); // await all the tasks Task.WaitAll(tasks.ToArray()); Console.WriteLine("END"); }
Task.WaitAll
accepts an array of tasks to be awaited and does not return anything.The execution goes like this:
START 1 is a valid value! 2 is a valid value! :( You cannot use 6! 5 is a valid value! :( You cannot use 3! 4 is a valid value! 8 is a valid value! 10 is a valid value! :( You cannot use 9! 7 is a valid value! 11 is a valid value! The exception message is: One or more errors occurred. ([EXCEPTION] Value cannot be 3) ([EXCEPTION] Value cannot be 6) ([EXCEPTION] Value cannot be 9) The exception type is: System.AggregateException Inner exception: [EXCEPTION] Value cannot be 3 FINALLY!
There are a few things to notice:
- the tasks are not executed in sequence: for example, 6 was printed before 4. Well, to be honest, we can say that
Console.WriteLine
printed the messages in that sequence, but maybe the tasks were executed in another different order (as you can deduce from the order of the error messages); - all the tasks are executed before jumping to the
catch
block; - the exception caught in the
catch
block is of typeSystem.AggregateException
; we’ll come back to it later; - the
InnerException
property of the exception being caught contains the info for the first exception that was thrown.
Error handling when using Task.WhenAll
Let’s replace
Task.WaitAll
withTask.WhenAll
.try { Console.WriteLine("START"); await Task.WhenAll(tasks); Console.WriteLine("END"); }
There are two main differences to notice when comparing
Task.WaitAll
andTask.WhenAll
:Task.WhenAll
accepts in input whatever type of collection (as long as it is anIEnumerable
);- it returns a
Task
that you have toawait
.
And what happens when we run the program?
START 2 is a valid value! 1 is a valid value! 4 is a valid value! :( You cannot use 3! 7 is a valid value! 5 is a valid value! :( You cannot use 6! 8 is a valid value! 10 is a valid value! 11 is a valid value! :( You cannot use 9! The exception message is: [EXCEPTION] Value cannot be 3 The exception type is: System.Exception FINALLY!
Again, there are a few things to notice:
- just as before, the messages are not printed in order;
- the exception message contains the message for the first exception thrown;
- the exception is of type
System.Exception
, and notSystem.AggregateException
as we saw before.
This means that the first exception breaks everything, and you lose the info about the other exceptions that were thrown.
📩 but now, a question for you: we learned that, when using
Task.WhenAll
, only the first exception gets caught by thecatch
block. What happens to the other exceptions? How can we retrieve them? Drop a message in the comment below ⬇️Comparing Task.WaitAll and Task.WhenAll
Task.WaitAll and Task.WhenAll are similar but not identical.
Task.WaitAll
should be used when you are in a synchronous context and need to block the current thread until all tasks are complete. This is common in simple old-style console applications or scenarios where asynchronous programming is not required. However, it is not recommended in UI or modern ASP.NET applications because it can cause deadlocks or freeze the UI.Task.WhenAll
is preferred in modern C# code, especially in asynchronous methods (where you can useasync Task
). It allows you to await the completion of multiple tasks without blocking the calling thread, making it suitable for environments where responsiveness is important. It also enables easier composition of continuations and better exception handling.Let’s wrap it up in a table:
Feature Task.WaitAll Task.WhenAll Return Type void
Task
orTask<TResult[]>
Blocking/Non-blocking Blocking (waits synchronously) Non-blocking (returns a Task) Exception Handling Throws AggregateException immediately Exceptions observed when awaited Usage Context Synchronous code (e.g., console apps) Asynchronous code (e.g., async methods) Continuation Not possible (since it blocks) Possible (use .ContinueWith
orawait
)Deadlock Risk Higher in UI contexts Lower (if properly awaited) Bonus tip: get the best out of AggregateException
We can expand a bit on the
AggregateException
type.That specific type of exception acts as a container for all the exceptions thrown when using
Task.WaitAll
.It contains a property named
InnerExceptions
that contains all the exceptions thrown so that you can access them using an Enumerator.A common example is this:
if (ex is AggregateException aggEx) { Console.WriteLine("There are {0} exceptions in the aggregate exception.", aggEx.InnerExceptions.Count); foreach (var innerEx in aggEx.InnerExceptions) { Console.WriteLine("Inner exception: {0}", innerEx.Message); } }
Further readings
This article is all about handling the unhappy path.
If you want to learn more about
Task.WaitAll
andTask.WhenAll
, I’d suggest you read the following two articles that I find totally interesting and well-written:🔗 Understanding Task.WaitAll and Task.WhenAll in C# | Muhammad Umair
and
🔗 Understanding WaitAll and WhenAll in .NET | Prasad Raveendran
This article first appeared on Code4IT 🐧
But, if you don’t know what asynchronous programming is and how to use TAP in C#, I’d suggest you start from the basics with this article:
🔗 First steps with asynchronous programming in C# | Code4IT
Wrapping up
I hope you enjoyed this article! Let’s keep in touch on LinkedIn, Twitter or BlueSky! 🤜🤛
Happy coding!
🐧
- the tasks are not executed in sequence: for example, 6 was printed before 4. Well, to be honest, we can say that
-
Top 6 Performance Tips when dealing with strings in C# 12 and .NET 8 | Code4IT
Small changes sometimes make a huge difference. Learn these 6 tips to improve the performance of your application just by handling strings correctly.
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.
– DavideSometimes, just a minor change makes a huge difference. Maybe you won’t notice it when performing the same operation a few times. Still, the improvement is significant when repeating the operation thousands of times.
In this article, we will learn five simple tricks to improve the performance of your application when dealing with strings.
Note: this article is part of C# Advent Calendar 2023, organized by Matthew D. Groves: it’s maybe the only Christmas tradition I like (yes, I’m kind of a Grinch 😂).
Benchmark structure, with dependencies
Before jumping to the benchmarks, I want to spend a few words on the tools I used for this article.
The project is a .NET 8 class library running on a laptop with an i5 processor.
Running benchmarks with BenchmarkDotNet
I’m using BenchmarkDotNet to create benchmarks for my code. BenchmarkDotNet is a library that runs your methods several times, captures some metrics, and generates a report of the executions. If you follow my blog, you might know I’ve used it several times – for example, in my old article “Enum.HasFlag performance with BenchmarkDotNet”.
All the benchmarks I created follow the same structure:
[MemoryDiagnoser] public class BenchmarkName() { [Params(1, 5, 10)] // clearly, I won't use these values public int Size; public string[] AllStrings { get; set; } [IterationSetup] public void Setup() { AllStrings = StringArrayGenerator.Generate(Size, "hello!", "HELLO!"); } [Benchmark(Baseline=true)] public void FirstMethod() { //omitted } [Benchmark] public void SecondMethod() { //omitted } }
In short:
- the class is marked with the
[MemoryDiagnoser]
attribute: the benchmark will retrieve info for both time and memory usage; - there is a property named
Size
with the attribute[Params]
: this attribute lists the possible values for theSize
property; - there is a method marked as
[IterationSetup]
: this method runs before every single execution, takes the value from theSize
property, and initializes theAllStrings
array; - the methods that are parts of the benchmark are marked with the
[Benchmark]
attribute.
Generating strings with Bogus
I relied on Bogus to create dummy values. This NuGet library allows you to generate realistic values for your objects with a great level of customization.
The string array generation strategy is shared across all the benchmarks, so I moved it to a static method:
public static class StringArrayGenerator { public static string[] Generate(int size, params string[] additionalStrings) { string[] array = new string[size]; Faker faker = new Faker(); List<string> fixedValues = [ string.Empty, " ", "\n \t", null ]; if (additionalStrings != null) fixedValues.AddRange(additionalStrings); for (int i = 0; i < array.Length; i++) { if (Random.Shared.Next() % 4 == 0) { array[i] = Random.Shared.GetItems<string>(fixedValues.ToArray(), 1).First(); } else { array[i] = faker.Lorem.Word(); } } return array; } }
Here I have a default set of predefined values (
[string.Empty, " ", "\n \t", null]
), which can be expanded with the values coming from theadditionalStrings
array. These values are then placed in random positions of the array.In most cases, though, the value of the string is defined by Bogus.
Generating plots with chartbenchmark.net
To generate the plots you will see in this article, I relied on chartbenchmark.net, a fantastic tool that transforms the output generated by BenchmarkDotNet on the console in a dynamic, customizable plot. This tool created by Carlos Villegas is available on GitHub, and it surely deserves a star!
Please note that all the plots in this article have a Log10 scale: this scale allows me to show you the performance values of all the executions in the same plot. If I used the Linear scale, you would be able to see only the biggest values.
We are ready. It’s time to run some benchmarks!
Tip #1: StringBuilder is (almost always) better than String Concatenation
Let’s start with a simple trick: if you need to concatenate strings, using a StringBuilder is generally more efficient than concatenating string.
[MemoryDiagnoser] public class StringBuilderVsConcatenation() { [Params(4, 100, 10_000, 100_000)] public int Size; public string[] AllStrings { get; set; } [IterationSetup] public void Setup() { AllStrings = StringArrayGenerator.Generate(Size, "hello!", "HELLO!"); } [Benchmark] public void WithStringBuilder() { StringBuilder sb = new StringBuilder(); foreach (string s in AllStrings) { sb.Append(s); } var finalString = sb.ToString(); } [Benchmark] public void WithConcatenation() { string finalString = ""; foreach (string s in AllStrings) { finalString += s; } } }
Whenever you concatenate strings with the
+
sign, you create a new instance of astring
. This operation takes some time and allocates memory for every operation.On the contrary, using a
StringBuilder
object, you can add the strings in memory and generate the final string using a performance-wise method.Here’s the result table:
Method Size Mean Error StdDev Median Ratio RatioSD Allocated Alloc Ratio WithStringBuilder 4 4.891 us 0.5568 us 1.607 us 4.750 us 1.00 0.00 1016 B 1.00 WithConcatenation 4 3.130 us 0.4517 us 1.318 us 2.800 us 0.72 0.39 776 B 0.76 WithStringBuilder 100 7.649 us 0.6596 us 1.924 us 7.650 us 1.00 0.00 4376 B 1.00 WithConcatenation 100 13.804 us 1.1970 us 3.473 us 13.800 us 1.96 0.82 51192 B 11.70 WithStringBuilder 10000 113.091 us 4.2106 us 12.081 us 111.000 us 1.00 0.00 217200 B 1.00 WithConcatenation 10000 74,512.259 us 2,111.4213 us 6,058.064 us 72,593.050 us 666.43 91.44 466990336 B 2,150.05 WithStringBuilder 100000 1,037.523 us 37.1009 us 108.225 us 1,012.350 us 1.00 0.00 2052376 B 1.00 WithConcatenation 100000 7,469,344.914 us 69,720.9843 us 61,805.837 us 7,465,779.900 us 7,335.08 787.44 46925872520 B 22,864.17 Let’s see it as a plot.
Beware of the scale in the diagram!: it’s a Log10 scale, so you’d better have a look at the value displayed on the Y-axis.
As you can see, there is a considerable performance improvement.
There are some remarkable points:
- When there are just a few strings to concatenate, the
+
operator is more performant, both on timing and allocated memory; - When you need to concatenate 100000 strings, the concatenation is ~7000 times slower than the string builder.
In conclusion, use the
StringBuilder
to concatenate more than 5 or 6 strings. Use the string concatenation for smaller operations.Edit 2024-01-08: turn out that
string.Concat
has an overload that accepts an array of strings.string.Concat(string[])
is actually faster than using the StringBuilder. Read more this article by Robin Choffardet.Tip #2: EndsWith(string) vs EndsWith(char): pick the right overload
One simple improvement can be made if you use
StartsWith
orEndsWith
, passing a single character.There are two similar overloads: one that accepts a
string
, and one that accepts achar
.[MemoryDiagnoser] public class EndsWithStringVsChar() { [Params(100, 1000, 10_000, 100_000, 1_000_000)] public int Size; public string[] AllStrings { get; set; } [IterationSetup] public void Setup() { AllStrings = StringArrayGenerator.Generate(Size); } [Benchmark(Baseline = true)] public void EndsWithChar() { foreach (string s in AllStrings) { _ = s?.EndsWith('e'); } } [Benchmark] public void EndsWithString() { foreach (string s in AllStrings) { _ = s?.EndsWith("e"); } } }
We have the following results:
Method Size Mean Error StdDev Median Ratio EndsWithChar 100 2.189 us 0.2334 us 0.6771 us 2.150 us 1.00 EndsWithString 100 5.228 us 0.4495 us 1.2970 us 5.050 us 2.56 EndsWithChar 1000 12.796 us 1.2006 us 3.4831 us 12.200 us 1.00 EndsWithString 1000 30.434 us 1.8783 us 5.4492 us 29.250 us 2.52 EndsWithChar 10000 25.462 us 2.0451 us 5.9658 us 23.950 us 1.00 EndsWithString 10000 251.483 us 18.8300 us 55.2252 us 262.300 us 10.48 EndsWithChar 100000 209.776 us 18.7782 us 54.1793 us 199.900 us 1.00 EndsWithString 100000 826.090 us 44.4127 us 118.5465 us 781.650 us 4.14 EndsWithChar 1000000 2,199.463 us 74.4067 us 217.0480 us 2,190.600 us 1.00 EndsWithString 1000000 7,506.450 us 190.7587 us 562.4562 us 7,356.250 us 3.45 Again, let’s generate the plot using the Log10 scale:
They appear to be almost identical, but look closely: based on this benchmark, when we have 10000, using
EndsWith(string)
is 10x slower thanEndsWith(char)
.Also, here, the duration ratio on the 1.000.000-items array is ~3.5. At first, I thought there was an error on the benchmark, but when rerunning it on the benchmark, the ratio did not change.
It looks like you have the best improvement ratio when the array has ~10.000 items.
Tip #3: IsNullOrEmpty vs IsNullOrWhitespace vs IsNullOrEmpty + Trim
As you might know,
string.IsNullOrWhiteSpace
performs stricter checks thanstring.IsNullOrEmpty
.(If you didn’t know, have a look at this quick explanation of the cases covered by these methods).
Does it affect performance?
To demonstrate it, I have created three benchmarks: one for
string.IsNullOrEmpty
, one forstring.IsNullOrWhiteSpace
, and another one that lays in between: it first callsTrim()
on the string, and then callsstring.IsNullOrEmpty
.[MemoryDiagnoser] public class StringEmptyBenchmark { [Params(100, 1000, 10_000, 100_000, 1_000_000)] public int Size; public string[] AllStrings { get; set; } [IterationSetup] public void Setup() { AllStrings = StringArrayGenerator.Generate(Size); } [Benchmark(Baseline = true)] public void StringIsNullOrEmpty() { foreach (string s in AllStrings) { _ = string.IsNullOrEmpty(s); } } [Benchmark] public void StringIsNullOrEmptyWithTrim() { foreach (string s in AllStrings) { _ = string.IsNullOrEmpty(s?.Trim()); } } [Benchmark] public void StringIsNullOrWhitespace() { foreach (string s in AllStrings) { _ = string.IsNullOrWhiteSpace(s); } } }
We have the following values:
Method Size Mean Error StdDev Ratio StringIsNullOrEmpty 100 1.723 us 0.2302 us 0.6715 us 1.00 StringIsNullOrEmptyWithTrim 100 2.394 us 0.3525 us 1.0282 us 1.67 StringIsNullOrWhitespace 100 2.017 us 0.2289 us 0.6604 us 1.45 StringIsNullOrEmpty 1000 10.885 us 1.3980 us 4.0781 us 1.00 StringIsNullOrEmptyWithTrim 1000 20.450 us 1.9966 us 5.8240 us 2.13 StringIsNullOrWhitespace 1000 13.160 us 1.0851 us 3.1482 us 1.34 StringIsNullOrEmpty 10000 18.717 us 1.1252 us 3.2464 us 1.00 StringIsNullOrEmptyWithTrim 10000 52.786 us 1.2208 us 3.5222 us 2.90 StringIsNullOrWhitespace 10000 46.602 us 1.2363 us 3.4668 us 2.54 StringIsNullOrEmpty 100000 168.232 us 12.6948 us 36.0129 us 1.00 StringIsNullOrEmptyWithTrim 100000 439.744 us 9.3648 us 25.3182 us 2.71 StringIsNullOrWhitespace 100000 394.310 us 7.8976 us 20.5270 us 2.42 StringIsNullOrEmpty 1000000 2,074.234 us 64.3964 us 186.8257 us 1.00 StringIsNullOrEmptyWithTrim 1000000 4,691.103 us 112.2382 us 327.4040 us 2.28 StringIsNullOrWhitespace 1000000 4,198.809 us 83.6526 us 161.1702 us 2.04 As you can see from the Log10 table, the results are pretty similar:
On average,
StringIsNullOrWhitespace
is ~2 times slower thanStringIsNullOrEmpty
.So, what should we do? Here’s my two cents:
- For all the data coming from the outside (passed as input to your system, received from an API call, read from the database), use
string.IsNUllOrWhiteSpace
: this way you can ensure that you are not receiving unexpected data; - If you read data from an external API, customize your JSON deserializer to convert whitespace strings as empty values;
- Needless to say, choose the proper method depending on the use case. If a string like “\n \n \t” is a valid value for you, use
string.IsNullOrEmpty
.
Tip #4: ToUpper vs ToUpperInvariant vs ToLower vs ToLowerInvariant: they look similar, but they are not
Even though they look similar, there is a difference in terms of performance between these four methods.
[MemoryDiagnoser] public class ToUpperVsToLower() { [Params(100, 1000, 10_000, 100_000, 1_000_000)] public int Size; public string[] AllStrings { get; set; } [IterationSetup] public void Setup() { AllStrings = StringArrayGenerator.Generate(Size); } [Benchmark] public void WithToUpper() { foreach (string s in AllStrings) { _ = s?.ToUpper(); } } [Benchmark] public void WithToUpperInvariant() { foreach (string s in AllStrings) { _ = s?.ToUpperInvariant(); } } [Benchmark] public void WithToLower() { foreach (string s in AllStrings) { _ = s?.ToLower(); } } [Benchmark] public void WithToLowerInvariant() { foreach (string s in AllStrings) { _ = s?.ToLowerInvariant(); } } }
What will this benchmark generate?
Method Size Mean Error StdDev Median P95 Ratio WithToUpper 100 9.153 us 0.9720 us 2.789 us 8.200 us 14.980 us 1.57 WithToUpperInvariant 100 6.572 us 0.5650 us 1.639 us 6.200 us 9.400 us 1.14 WithToLower 100 6.881 us 0.5076 us 1.489 us 7.100 us 9.220 us 1.19 WithToLowerInvariant 100 6.143 us 0.5212 us 1.529 us 6.100 us 8.400 us 1.00 WithToUpper 1000 69.776 us 9.5416 us 27.833 us 68.650 us 108.815 us 2.60 WithToUpperInvariant 1000 51.284 us 7.7945 us 22.860 us 38.700 us 89.290 us 1.85 WithToLower 1000 49.520 us 5.6085 us 16.449 us 48.100 us 79.110 us 1.85 WithToLowerInvariant 1000 27.000 us 0.7370 us 2.103 us 26.850 us 30.375 us 1.00 WithToUpper 10000 241.221 us 4.0480 us 3.588 us 240.900 us 246.560 us 1.68 WithToUpperInvariant 10000 339.370 us 42.4036 us 125.028 us 381.950 us 594.760 us 1.48 WithToLower 10000 246.861 us 15.7924 us 45.565 us 257.250 us 302.875 us 1.12 WithToLowerInvariant 10000 143.529 us 2.1542 us 1.910 us 143.500 us 146.105 us 1.00 WithToUpper 100000 2,165.838 us 84.7013 us 223.137 us 2,118.900 us 2,875.800 us 1.66 WithToUpperInvariant 100000 1,885.329 us 36.8408 us 63.548 us 1,894.500 us 1,967.020 us 1.41 WithToLower 100000 1,478.696 us 23.7192 us 50.547 us 1,472.100 us 1,571.330 us 1.10 WithToLowerInvariant 100000 1,335.950 us 18.2716 us 35.203 us 1,330.100 us 1,404.175 us 1.00 WithToUpper 1000000 20,936.247 us 414.7538 us 1,163.014 us 20,905.150 us 22,928.350 us 1.64 WithToUpperInvariant 1000000 19,056.983 us 368.7473 us 287.894 us 19,085.400 us 19,422.880 us 1.41 WithToLower 1000000 14,266.714 us 204.2906 us 181.098 us 14,236.500 us 14,593.035 us 1.06 WithToLowerInvariant 1000000 13,464.127 us 266.7547 us 327.599 us 13,511.450 us 13,926.495 us 1.00 Let’s see it as the usual Log10 plot:
We can notice a few points:
- The ToUpper family is generally slower than the ToLower family;
- The Invariant family is faster than the non-Invariant one; we will see more below;
So, if you have to normalize strings using the same casing,
ToLowerInvariant
is the best choice.Tip #5: OrdinalIgnoreCase vs InvariantCultureIgnoreCase: logically (almost) equivalent, but with different performance
Comparing strings is trivial: the
string.Compare
method is all you need.There are several modes to compare strings: you can specify the comparison rules by setting the
comparisonType
parameter, which accepts aStringComparison
value.[MemoryDiagnoser] public class StringCompareOrdinalVsInvariant() { [Params(100, 1000, 10_000, 100_000, 1_000_000)] public int Size; public string[] AllStrings { get; set; } [IterationSetup] public void Setup() { AllStrings = StringArrayGenerator.Generate(Size, "hello!", "HELLO!"); } [Benchmark(Baseline = true)] public void WithOrdinalIgnoreCase() { foreach (string s in AllStrings) { _ = string.Equals(s, "hello!", StringComparison.OrdinalIgnoreCase); } } [Benchmark] public void WithInvariantCultureIgnoreCase() { foreach (string s in AllStrings) { _ = string.Equals(s, "hello!", StringComparison.InvariantCultureIgnoreCase); } } }
Let’s see the results:
Method Size Mean Error StdDev Ratio WithOrdinalIgnoreCase 100 2.380 us 0.2856 us 0.8420 us 1.00 WithInvariantCultureIgnoreCase 100 7.974 us 0.7817 us 2.3049 us 3.68 WithOrdinalIgnoreCase 1000 11.316 us 0.9170 us 2.6603 us 1.00 WithInvariantCultureIgnoreCase 1000 35.265 us 1.5455 us 4.4591 us 3.26 WithOrdinalIgnoreCase 10000 20.262 us 1.1801 us 3.3668 us 1.00 WithInvariantCultureIgnoreCase 10000 225.892 us 4.4945 us 12.5289 us 11.41 WithOrdinalIgnoreCase 100000 148.270 us 11.3234 us 32.8514 us 1.00 WithInvariantCultureIgnoreCase 100000 1,811.144 us 35.9101 us 64.7533 us 12.62 WithOrdinalIgnoreCase 1000000 2,050.894 us 59.5966 us 173.8460 us 1.00 WithInvariantCultureIgnoreCase 1000000 18,138.063 us 360.1967 us 986.0327 us 8.87 As you can see, there’s a HUGE difference between Ordinal and Invariant.
When dealing with 100.000 items,
StringComparison.InvariantCultureIgnoreCase
is 12 times slower thanStringComparison.OrdinalIgnoreCase
!Why? Also, why should we use one instead of the other?
Have a look at this code snippet:
var s1 = "Aa"; var s2 = "A" + new string('\u0000', 3) + "a"; string.Equals(s1, s2, StringComparison.InvariantCultureIgnoreCase); //True string.Equals(s1, s2, StringComparison.OrdinalIgnoreCase); //False
As you can see,
s1
ands2
represent equivalent, but not equal, strings. We can then deduce thatOrdinalIgnoreCase
checks for the exact values of the characters, whileInvariantCultureIgnoreCase
checks the string’s “meaning”.So, in most cases, you might want to use
OrdinalIgnoreCase
(as always, it depends on your use case!)Tip #6: Newtonsoft vs System.Text.Json: it’s a matter of memory allocation, not time
For the last benchmark, I created the exact same model used as an example in the official documentation.
This benchmark aims to see which JSON serialization library is faster: Newtonsoft or System.Text.Json?
[MemoryDiagnoser] public class JsonSerializerComparison { [Params(100, 10_000, 1_000_000)] public int Size; List<User?> Users { get; set; } [IterationSetup] public void Setup() { Users = UsersCreator.GenerateUsers(Size); } [Benchmark(Baseline = true)] public void WithJson() { foreach (User? user in Users) { var asString = System.Text.Json.JsonSerializer.Serialize(user); _ = System.Text.Json.JsonSerializer.Deserialize<User?>(asString); } } [Benchmark] public void WithNewtonsoft() { foreach (User? user in Users) { string asString = Newtonsoft.Json.JsonConvert.SerializeObject(user); _ = Newtonsoft.Json.JsonConvert.DeserializeObject<User?>(asString); } } }
As you might know, the .NET team has added lots of performance improvements to the JSON Serialization functionalities, and you can really see the difference!
Method Size Mean Error StdDev Median Ratio RatioSD Gen0 Gen1 Allocated Alloc Ratio WithJson 100 2.063 ms 0.1409 ms 0.3927 ms 1.924 ms 1.00 0.00 – – 292.87 KB 1.00 WithNewtonsoft 100 4.452 ms 0.1185 ms 0.3243 ms 4.391 ms 2.21 0.39 – – 882.71 KB 3.01 WithJson 10000 44.237 ms 0.8787 ms 1.3936 ms 43.873 ms 1.00 0.00 4000.0000 1000.0000 29374.98 KB 1.00 WithNewtonsoft 10000 78.661 ms 1.3542 ms 2.6090 ms 78.865 ms 1.77 0.08 14000.0000 1000.0000 88440.99 KB 3.01 WithJson 1000000 4,233.583 ms 82.5804 ms 113.0369 ms 4,202.359 ms 1.00 0.00 484000.0000 1000.0000 2965741.56 KB 1.00 WithNewtonsoft 1000000 5,260.680 ms 101.6941 ms 108.8116 ms 5,219.955 ms 1.24 0.04 1448000.0000 1000.0000 8872031.8 KB 2.99 As you can see, Newtonsoft is 2x slower than System.Text.Json, and it allocates 3x the memory compared with the other library.
So, well, if you don’t use library-specific functionalities, I suggest you replace Newtonsoft with System.Text.Json.
Wrapping up
In this article, we learned that even tiny changes can make a difference in the long run.
Let’s recap some:
- Using StringBuilder is generally WAY faster than using string concatenation unless you need to concatenate 2 to 4 strings;
- Sometimes, the difference is not about execution time but memory usage;
- EndsWith and StartsWith perform better if you look for a char instead of a string. If you think of it, it totally makes sense!
- More often than not, string.IsNullOrWhiteSpace performs better checks than string.IsNullOrEmpty; however, there is a huge difference in terms of performance, so you should pick the correct method depending on the usage;
- ToUpper and ToLower look similar; however, ToLower is quite faster than ToUpper;
- Ordinal and Invariant comparison return the same value for almost every input; but Ordinal is faster than Invariant;
- Newtonsoft performs similarly to System.Text.Json, but it allocates way more memory.
This article first appeared on Code4IT 🐧
My suggestion is always the same: take your time to explore the possibilities! Toy with your code, try to break it, benchmark it. You’ll find interesting takes!
I hope you enjoyed this article! Let’s keep in touch on Twitter or LinkedIn! 🤜🤛
Happy coding!
🐧
- the class is marked with the
-
[ENG] MVPbuzzChat with Davide Bellone
About the author
Davide Bellone is a Principal Backend Developer with more than 10 years of professional experience with Microsoft platforms and frameworks.
He loves learning new things and sharing these learnings with others: that’s why he writes on this blog and is involved as speaker at tech conferences.
He’s a Microsoft MVP 🏆, conference speaker (here’s his Sessionize Profile) and content creator on LinkedIn.
-
Elastic Grid Scroll: Creating Lag-Based Layout Animations with GSAP ScrollSmoother
You’ve probably seen this kind of scroll effect before, even if it doesn’t have a name yet. (Honestly, we need a dictionary for all these weird and wonderful web interactions. If you’ve got a talent for naming things…do it. Seriously. The internet is waiting.)
Imagine a grid of images. As you scroll, the columns don’t move uniformly but instead, the center columns react faster, while those on the edges trail behind slightly. It feels soft, elastic, and physical, almost like scrolling with weight, or elasticity.
You can see this amazing effect on sites like yzavoku.com (and I’m sure there’s a lot more!).
So what better excuse to use the now-free GSAP ScrollSmoother? We can recreate it easily, with great performance and full control. Let’s have a look!
What We’re Building
We’ll take CSS grid based layout and add some magic:
- Inertia-based scrolling using ScrollSmoother
- Per-column lag, calculated dynamically based on distance from the center
- A layout that adapts to column changes
HTML Structure
Let’s set up the markup with figures in a grid:
<div class="grid"> <figure class="grid__item"> <div class="grid__item-img" style="background-image: url(assets/1.webp)"></div> <figcaption class="grid__item-caption">Zorith - L91</figcaption> </figure> <!-- Repeat for more items --> </div>
Inside the grid, we have many .grid__item figures, each with a background image and a label. These will be dynamically grouped into columns by JavaScript, based on how many columns CSS defines.
CSS Grid Setup
.grid { display: grid; grid-template-columns: repeat(var(--column-count), minmax(var(--column-size), 1fr)); grid-column-gap: var(--c-gap); grid-row-gap: var(--r-gap); } .grid__column { display: flex; flex-direction: column; gap: var(--c-gap); }
We define all the variables in our root.
In our JavaScript then, we’ll change the DOM structure by inserting .grid__column wrappers around groups of items, one per colum, so we can control their motion individually. Why are we doing this? It’s a bit lighter to move columns rather then each individual item.
JavaScript + GSAP ScrollSmoother
Let’s walk through the logic step-by-step.
1. Enable Smooth Scrolling and Lag Effects
gsap.registerPlugin(ScrollTrigger, ScrollSmoother); const smoother = ScrollSmoother.create({ smooth: 1, // Inertia intensity effects: true, // Enable per-element scroll lag normalizeScroll: true, // Fixes mobile inconsistencies });
This activates GSAP’s smooth scroll layer. The
effects: true
flag lets us animate elements with lag, no scroll listeners needed.2. Group Items Into Columns Based on CSS
const groupItemsByColumn = () => { const gridStyles = window.getComputedStyle(grid); const columnsRaw = gridStyles.getPropertyValue('grid-template-columns'); const numColumns = columnsRaw.split(' ').filter(Boolean).length; const columns = Array.from({ length: numColumns }, () => []); // Initialize column arrays // Distribute grid items into column buckets grid.querySelectorAll('.grid__item').forEach((item, index) => { columns[index % numColumns].push(item); }); return { columns, numColumns }; };
This method groups your grid items into arrays, one for each visual column, using the actual number of columns calculated from the CSS.
3. Create Column Wrappers and Assign Lag
const buildGrid = (columns, numColumns) => { const fragment = document.createDocumentFragment(); // Efficient DOM batch insertion const mid = (numColumns - 1) / 2; // Center index (can be fractional) const columnContainers = []; // Loop over each column columns.forEach((column, i) => { const distance = Math.abs(i - mid); // Distance from center column const lag = baseLag + distance * lagScale; // Lag based on distance from center const columnContainer = document.createElement('div'); // New column wrapper columnContainer.className = 'grid__column'; // Append items to column container column.forEach((item) => columnContainer.appendChild(item)); fragment.appendChild(columnContainer); // Add to fragment columnContainers.push({ element: columnContainer, lag }); // Save for lag effect setup }); grid.appendChild(fragment); // Add all columns to DOM at once return columnContainers; };
The lag value increases the further a column is from the center, creating that elastic “catch up” feel during scroll.
4. Apply Lag Effects to Each Column
const applyLagEffects = (columnContainers) => { columnContainers.forEach(({ element, lag }) => { smoother.effects(element, { speed: 1, lag }); // Apply individual lag per column }); };
ScrollSmoother handles all the heavy lifting, we just pass the desired lag.
5. Handle Layout on Resize
// Rebuild the layout only if the number of columns has changed on window resize window.addEventListener('resize', () => { const newColumnCount = getColumnCount(); if (newColumnCount !== currentColumnCount) { init(); } });
This ensures our layout stays correct across breakpoints and column count changes (handled via CSS).
And that’s it!
Variation 4 Extend This Further
Now, there’s lots of ways to build upon this and add more jazz!
For example, you could:
- add scroll-triggered opacity or scale animations
- use scroll velocity to control effects (see demo 2)
- adapt this pattern for horizontal scroll layouts
Exploring Variations
Once you have the core concept in place, there are four demo variations you can explore. Each one shows how different lag values and scroll-based interactions can influence the experience.
You can adjust which columns respond faster, or play with subtle scaling and transforms based on scroll velocity. Even small changes can shift the rhythm and tone of the layout in interesting ways. And don’t forget: changing the look of the grid itself, like the image ratio or gaps, will give this a whole different feel!
Now it’s your turn. Tweak it, break it, rebuild it, and make something cool.
I really hope you enjoy this effect! Thanks for checking by 🙂
-
Explained (With Code Snippets Included)
Explained (With Code Snippets Included)
Source link -
Building a Physics-Based Character Controller with the Help of AI
Creating a third-person character controller involves more than just moving an object around a 3D scene. Realistic movement, grounded physics, responsive jumping, and animation blending are essential for a polished feel. This article explores how these elements can be assembled — not through traditional manual coding, but via AI-assisted development using Bolt.new, a browser-based AI-assisted development tool that generates web code from natural language prompts, backed by Claude 3.7 Sonnet and Claude 3.5 Sonnet LLMs. It provides a lightweight environment where developers can focus on describing functionality rather than writing boilerplate.
For this character controller, Bolt handled tasks like setting up physics, integrating animations, and managing input systems, making it easier to test ideas and iterate quickly without switching between tools or writing everything from scratch.
If you’re curious to learn more, check out this article on Codrops, which also explores the platform’s capabilities and showcases another real-world project built entirely with AI.
The final project is powered by React Three Fiber, Three.js, and Rapier, and showcases how a designer or developer can create complex, interactive 3D experiences by guiding AI — focusing on behavior and structure rather than syntax.
Step 1: Setting Up Physics with a Capsule and Ground
The character controller begins with a simple setup: a capsule collider for the player and a ground plane to interact with. Rapier, a fast and lightweight physics engine built in WebAssembly, handles gravity, rigid body dynamics, and collisions. This forms the foundation for player movement and world interaction.
The capsule shape was chosen for its stability when sliding across surfaces and climbing over small obstacles — a common pattern in real-time games.
Step 2: Real-Time Tuning with a GUI
To enable rapid iteration and balance gameplay feel, a visual GUI was introduced (using Leva.js). This panel exposes parameters such as:
- Player movement speed
- Jump force
- Gravity scale
- Follow camera offset
- Debug toggles
By integrating this directly into the experience, developers can tune the controller live without needing to edit or recompile code, speeding up testing and design decisions.
Step 3: Ground Detection with Raycasting
A raycast is used to detect whether the player is grounded. This simple yet effective check prevents the character from jumping mid-air or triggering multiple jumps in sequence.
The logic is executed on every frame, casting a ray downward from the base of the capsule collider. When contact is confirmed, the jump input is enabled. This technique also allows smooth transitions between grounded and falling states in the animation system.
Step 4: Integrating a Rigged Character with Animation States
The visual character uses a rigged GLB model via Mixamo, with three key animations: Idle, Run, and Fall. These are integrated as follows:
- The GLB character is attached as a child of the capsule collider
- The animation state switches dynamically based on velocity and grounded status
- Transitions are handled via animation blending for a natural feel
This setup keeps the visuals in sync with physics, while preserving modular control over the physical capsule.
Step 5: World Building and Asset Integration
The environment was arranged in Blender, then exported as a single
.glb
file and imported into the bolt.new project scene. This approach allows for efficient scene composition while keeping asset management simple.For web, using
.glb
keeps geometry and textures bundled together. To maintain performance, it’s recommended to keep textures at 1024×1024 resolution or other square power-of-two sizes (e.g. 256, 512, 2048). This ensures optimal GPU memory usage and faster load times across devices.Special thanks to KayLousberg for the low-poly 3D kit used for prototyping.
Step 6: Cross-Platform Input Support
The controller was designed to work seamlessly across desktop, mobile, and gamepad platforms — all built using AI-generated logic through Bolt.
Gamepad support was added using the Gamepad API, allowing players to plug in a controller and play with analog input.
On desktop, the controller uses standard keyboard input (WASD or arrow keys) and mouse movement for camera control.
On mobile, AI-generated code enabled an on-screen joystick and jump button, making the game fully touch-compatible.
All input types control the same physics-driven character, ensuring consistent behavior across devices — whether you’re playing on a laptop, touchscreen, or game controller.
This cross-platform support was implemented entirely through natural language prompts, showcasing how AI can translate high-level intent into working input systems.
The Role of AI in the Workflow
What makes this controller unique isn’t the mechanics — it’s the process. Every system was generated by AI through descriptive prompts, allowing the developer to work more like a creative director than a traditional engineer.
AI handled the boilerplate, the physics setup, the animation switching logic — all based on clear creative goals. This opens new doors for prototyping and interactive design, where iteration speed matters more than syntax.
This character controller demo includes:
- Capsule collider with physics
- Grounded detection via raycast
- State-driven animation blending
- GUI controls for tuning
- Environment interaction with static/dynamic objects
- Cross-Platform Input Support
It’s a strong starting point for creating browser-based games, interactive experiences, or prototyping new ideas — all with the help of AI.
Check out the full game built using this setup as a base: 🎮 Demo Game
Thanks for following along — have fun building 😊