Sr. Content Developer at Microsoft, working remotely in PA, TechBash conference organizer, former Microsoft MVP, Husband, Dad and Geek.
158826 stories
·
33 followers

Build a Raspberry Pi Pico route finder using Dijkstra’s algorithm

1 Share

In the 1950s, Dutch computer scientist Edsger Dijkstra developed a route-finding algorithm so important that, 70 years later, it is still part of the computer science curriculum. In this article, we are going to consider what Dijkstra’s algorithm does, how it works, and discover where it can be used. Along the way, we’ll brush up on some Python programming techniques, find some treasure, and build a pocket route finder using Raspberry Pi Pico

How does your satnav find your way home? How do networks decide which way to send data packets? How can a robot find its way around an obstacle? The answer to all these questions is by creating a graph of linked data nodes and then using something like Dijkstra’s algorithm to create routing data from the graph.

Dijkstra’s algorithm

Figure 1 shows the Pico Route Finder. It contains map information for locations in the UK and will tell you the distance and the routes between them. Let’s look at how Dijkstra’s algorithm makes it work, starting with how the algorithm came about.

Figure 1: The route finder contains a Raspberry Pi Pico running a MicroPython program

The story is that Dijkstra was having a coffee in Amsterdam and thinking about navigation. In the back of his mind, he was also wondering what he could write to demonstrate a new computer he was working on. After a few minutes of pondering, he came up with his algorithm, which boils down to three rules:

  1. Keep track of how much it costs to go to places
  2. Always use the lowest-cost route first when exploring
  3. Keep track of where you have been

Let’s see how we can use these rules to find the cheapest route to some treasure. Suppose we are visiting a castle, and the owner tells us:

“I want you to chart a path from the Hall to my Treasure Room, where I have placed a pile of gold coins. You can have all the coins in the Treasure Room minus the cost of getting there. Each door on your path has a cost. You can roam the castle as much as you like, but when you have finished you can meet me in the Treasure Room, tell me the path you have devised, and I’ll give you your reward.”

Scores on the doors

We look around the Hall and see three labelled doors, as shown in Figure 2. The labels say ‘Kitchen: Cost 5’, ‘Library: Cost 2’, ‘Treasure Room: Cost 100’. We could go straight to the Treasure Room, which would cost us 100 coins. Or we could try our luck at finding a cheaper route. Fortunately, we are computer scientists familiar with Dijkstra’s algorithm, so we open our notebook and draw a table. Each door tells us about a new room and the cost of getting there from that room. We enter this information into our table, starting with the Hall, which is where we are now.

Figure 2: You would expect the path to the Treasure Room to be expensive

Figure 3 shows our first table. It describes rooms we know about, whether we have visited them, the cost of getting to each room from the Hall, and the route back to the start of our journey. Dijkstra’s algorithm says that we should now find the lowest-cost unvisited room, travel to that room, and update our table. So, we go to the Library. 

Figure 3: We will update the values in the table as we learn more about the paths between rooms

Want gold, will travel

The doors in Figure 4 tell us it costs 2 to reach the Kitchen from the Library and 4 to reach the Armoury. They also tell us that it costs 2 to reach the Hall, but we already knew that from the door in the Hall. We use these numbers to update the table. Let’s start with the Armoury. It costs us 4 to get to the Armoury from the Library, and it costs 2 to get to the Library from the Hall. So, the total cost of getting to the Armoury from the Hall is 6. We add the Armoury to our table and note that the route back to the Hall from the Armoury starts by going to the Library and then going on to the Hall. 

Figure 4: We discover new information when we see the routes

Now we can update our table to save us some gold. Dijkstra’s algorithm tells us to keep track of journey costs. If we find a new cost that is less than one in the table, we need to update the table. It cost us 2 to get to the Library and it would cost 2 more to get from the Library to the Kitchen, making a total of 4. The cost of going directly from the Hall to the Kitchen was initially entered as 5 (the cost on the door in the Hall). But if we travel from the Hall to the Kitchen via the Library, we can save a gold coin. We update the table with this new information.

Figure 5 shows our updated table with out-of-date information crossed out. Our next stop will be the unvisited room with the lowest cost, which is the Kitchen. We go into the Kitchen and repeat this process until we end up in the Treasure room. 

Figure 5: The cost of getting to the Kitchen has reduced

The final reckoning

Figure 6 shows the table created during our travels. It tells us all we need to know. The cheapest route from the Hall to the Treasure Room costs 18 coins. We use the Route Back column in the table to find the way back from the Treasure Room to the Hall. The first step on the way back to the Hall is to visit the Bedroom. Then we look up the Route Back value for Bedroom to find the next step, and so on. The complete route back is Bedroom > Dining Hall > Kitchen > Library > Hall. You can work through this in Figure 6. We reverse this path to create a route from the Hall to the Treasure Room. 

Figure 6: The path to the Treasure Room has got progressively cheaper as we have explored the castle

Figure 7 shows the map of the castle that we built on our travels. This is not complete. It only shows the rooms that we know about. If you want to prove that the algorithm works, you can work through the route-finding process to build the table shown in Figure 6.

Figure 7: The numbers on the links do not reflect distances; they are the costs on the doors

Winning with Dijkstra 

The cleverness of the algorithm shines through when you notice how it has ignored places that are not on the best route. We never visited the Stables because routes there were more expensive than cheaper routes that we checked first. At the time Dijkstra came up with his theorem, computers had very limited main memory. His route-finding demonstration was limited to 64 locations because his program used six data bits to hold each location number. A huge part of Dijkstra’s initial triumph was fitting all the location information and the code into a very small amount of memory. 

Writing Dijkstra 

Rather than using a notebook to track costs, we can create our own cost-tracking program in Python. The first thing we need to do is create something to store each room:

class NodeRouteData:

def __init__(self, name, cost, route_back):

        self.name = name

        self.visited = False

        self.cost = cost

        self.route_back = route_back

The code above creates a class called NodeRouteData. This stores all the information about a location in the castle. We’re calling each location a ‘node’ because we might want to use this program to navigate things other than rooms in a castle. The class contains fields which map directly onto what we put in our notebook. When we create an instance of the class, we set the name, cost, and the route back from this node. Now let’s ask the user where they are starting from and where they are going:

start_name = input("Start name: ")

end_name = input("End name: ")

The statements above read the names of the start and end points and store them in variables called start_name and
end_name. Next, we need to make a node to represent the start location. We don’t need to make a node for the end location; we use the value in end_name to detect when we have found a route to it. 

start_node = NodeRouteData(start_name, 0, None)

The statement above creates a NodeRouteData called start_node, with the name set to start_name. The first argument to the constructor of NodeRouteData is the name of the node. The second argument is the cost of going there. For the start node this is 0, because it is where we start. The third argument is the route back (i.e. the place we go to on the way back to the start). Since this is the start, we have no route back, so this value is set to None. Now we need to put the start node in the table. We can use a Python dictionary to hold the table, and the name of a node will be used as the key to find that node in the dictionary.

nodes = {}

nodes[start_name] = start_node

The statements above create an empty dictionary called nodes and add the start room to it. You add an item to a dictionary by specifying a key (the thing that will be used to find the item), as shown above. Now we can start checking the doors in nodes and updating costs. We use a variable called current_node to refer to the node we are working on. At the start of our exploration, the current node will be the start node:

current_node = start_node

The statement above creates current_node and sets it to start_node. Now let’s kick off our loop:

while current_node.name != end_name:

The statement above starts a while loop that will cause the program to inspect nodes until it reaches one with the same name as the end of our route. Because the algorithm always chooses routes with the lowest cost, we will have found our cheapest route when the two names match. Inside the loop, we will do exactly what we did when we were physically exploring the castle. We look for doors and use the information on them to update our table. There might be more than one door, so we need to create a ‘door reading’ loop:

while True:

    name = input("Room name (empty to end): ")

    if name=="":

        break

The statements above create a loop that starts by asking for the name of a room from a door. If the name is an empty string, there are no more doors at this location, so the code uses a break to leave the door-reading loop. If the user has entered a name, the program asks for the cost of the route. 

cost_string = input("Cost :")

cost = int(cost_string)

The statements above set the variable cost_string to the string that the user types in and then convert this string into an integer stored in the variable cost. Now we can use this new cost value to update our table. The first thing we do is work out the cost of getting to the room on the door from the start location. This will be the cost of getting to this room, plus the cost on the door:

total_cost = cost + current_node.cost

We now have the name of a node and the total cost of getting to that node. If this is a node we haven’t seen before, we need to add it to the nodes. Let’s do that.

if name not in nodes:

    nodes[name]=NodeRouteData(name,
total_cost,current_node)

The code above checks the nodes dictionary to see if it contains an entry for the node with the name that was typed in. If the answer is no, the program adds a new node to the dictionary. We set the route back and the cost values for the new node. 

If the node is already in the dictionary, we need to check whether we have found a cheaper route to it. We can do this by adding an else part to the condition we just wrote. This code will run if the dictionary contains a node with the name on the door.

else:

    room=nodes[name]

    if total_cost < room.cost:

        room.cost = total_cost

        room.route_back = current_node

The code here gets a reference to the room from the dictionary and then compares the cost of using the newly discovered door with the stored cost of getting to that room. If the new route is cheaper, we update the room data. This is the programming equivalent of crossing out the cost and route-back entries in our notebook and writing new ones. 

Once all the door info from a room is known, the user will enter an empty room name and the room loop will end. Now we need to record that we’ve visited this room so we won’t go there again:

current_node.visited=True

Now that we have updated the route table, we need to decide where to go next. We need to find the next cheapest route in the table and go where that leads:

cheapest_node = None

for node in nodes.values():

    if node.visited:

        continue

    if cheapest_node == None or node.cost  < cheapest_node.cost:

        cheapest_node=node

This code sets the value of cheapest_node to the node which has not been visited and has the lowest cost. Now we can switch to it:

print(f"The next node is {cheapest_node.name}")

current_node = cheapest_node

These two statements tell the user where to go next and then set the value of current_node to the cheapest one. Note that the next node might not be through one of the doors that have just been discovered. At some point the loop will stop, because the cheapest_node becomes the one with the end name and the while loop will exit. This is where we tell the user the cost of the route and describe it to them. Displaying the cost of the route is the simpler of the two:

print(f"Cost: {current_node.cost}")

The search loop stops when the current_node refers to the node with the name of the end_node. So we just print out the cost of getting to the current node. This will print a cost of 18 coins. Now we build a route by working through all the route_back values of nodes in the route:

route = []

while current_node != None:

    route.append(current_node.name)

    current_node = current_node.route_back

The code above uses a loop to build a list called route. It starts at the end node and works back to the start. The start_node was created with a route_back value of None, so the loop will stop when it reaches the start. The loop adds the name of each node to the route list.

We now have a list of node names, but they are in the wrong order. We need to reverse them before printing them. 

route.reverse()

print("Route:", " -> ".join(route))

The two statements here reverse the route and then print out the names in the route, separated by ‘ -> ‘ strings. The rather wonderful join method works on a string, takes the elements in a collection, and inserts the string between each item in the collection. 

Hall -> Library -> Kitchen -> Dining Hall -> Bedroom -> Treasure Room

The statement above is the result of a successful run. The sample code for this article contains an implementation of the algorithm. If you’ve got this far, you are allowed to take a deep breath and feel very pleased with yourself. You now know how to implement Dijkstra’s algorithm. You also know more about Python classes, dictionaries, and references.

Map navigation

We can modify the castle navigation program to plot routes in the UK. First, we need a graph that describes routes between various locations. The word graph is used in lots of different contexts. The most familiar one is probably the Cartesian graph, which plots one value against another (for example, price against time). For routing, we use a node graph. This describes places (nodes) and routes between them. Here is one expressed as a JSON file.

Figure 8: A map of the data in the map_graph.json file; the example code contains a Python program that draws this map and animates the route-finding process

The code in the map_graph.json listing shows the first part of the graph. It contains a dictionary of cities indexed by name. Cities have a geographical location and a list of links and costs (distances) to neighbouring cities. A modified version of the castle navigation program uses this node graph to find the shortest route between two cities. The Pico Route Finder uses the same code to create routes and then display them on the LCD panel.

Dijkstra forever

Dijkstra’s algorithm works by finding all the lowest-cost routes in order and stopping when it finds the required destination. When navigating from London to Hull, it found a route to Truro (a long way away from both London and Hull) before it found the route to Hull. For this reason, pure Dijkstra is not used in modern navigation, which tries to reduce the amount of route searching by making a judgement on whether a proposed route is moving towards or away from the destination. But there are many occasions where it is still the best way to create a route, and it will always be part of how computer systems find their way around.

The post Build a Raspberry Pi Pico route finder using Dijkstra’s algorithm appeared first on Raspberry Pi.

Read the whole story
alvinashcraft
29 minutes ago
reply
Pennsylvania, USA
Share this story
Delete

Anthropic recommends a git worktree per agent. Your runtime infra makes that a problem.

1 Share
Abstract 3D digital visualization of tall magenta data spires on a dark grid, illustrating runtime infrastructure complexity for AI coding agents.

A developer supervising four coding agents has four changes in flight at once, each in its own git worktree. That isn’t an exotic setup anymore: Anthropic’s documentation now treats a worktree per session as the default way to run agents in parallel, and what was an expert workflow two years ago is the recommended starting point today.

The branches themselves aren’t new. Git made them cheap 20 years ago so developers could isolate changes and work on several things at once, but in practice a developer switched between branches and shipped one change at a time. That kept everything below the code layer singular: one continuous integration (CI) queue, one staging environment, one database everyone tested against. The number of changes contending for those shared resources was capped by headcount, and before agents, only larger teams ever hit the cap.

“Coding agents removed the cap. The branch can no longer stop at the code layer.”

Coding agents removed the cap. Those four branches are no longer something one developer rotates through. They are four active changes moving toward merge in parallel. The gap becomes unworkable: branching is free at the code layer and missing everywhere below it. Each change needs to exist all the way down the stack, not as a diff in a directory but as a running, testable version of the system. The branch can no longer stop at the code layer.

Parallel until the first shared resource

Code branches in milliseconds. A worktree gives each agent a private copy of the repository for the cost of a checkout, and 10 agents can work side by side without seeing each other’s edits.

The output shows up downstream. Telemetry from Faros AI across more than 10,000 developers found that teams with high AI adoption merge 98% more pull requests while review time grows 91%. Nothing downstream of code generation was sized for that arrival rate.

Then each change needs to run. There is one staging cluster, one seeded database, one message queue, one set of dependent services, and every branch that reaches this floor stops being parallel. Four agents produce four candidate changes in an afternoon, and all four line up behind the same shared environment to find out whether they work.

The queue is more expensive than it looks, because agents don’t wait well. An agent blocked on an environment either sits idle holding a stale view of the system or plows ahead validating against mocks, and the developer supervising it context-switches away. By the time the shared environment frees up, the cheap part of the work has to be partially redone.

The bottleneck isn’t code generation, and it isn’t review capacity alone. It’s the first shared resource a change touches, because a branch that can’t run is a branch that can’t be trusted.

“The bottleneck isn’t code generation, and it isn’t review capacity alone. It’s the first shared resource a change touches.”

Workflow diagram showing agent worktree branches running in parallel

A branch is a delta, not a copy

The way out is to stop treating branching as something git does and start treating it as something every layer does. Branch-based development names the pattern: each layer of the stack offers a cheap, instant, disposable branch primitive, so a change can exist end to end without duplicating anything it didn’t touch.

The mechanic is the one git established, and everyone has been living on for two decades: branches are cheap because they share everything unchanged and carry only the delta. The rest of the stack has been relearning that idea layer by layer ever since — share by default, isolate what changed.

Naming the pattern matters because each layer discovered it separately and called it something different. Worktrees, pipeline caching, preview deploys, database branching, and environment sandboxing sound like five unrelated features. They’re the same idea applied at five layers, and seeing that changes what you ask of the layers that lack it.

The upper layers learned this years ago

CI absorbed the lesson a decade ago. Every branch gets its own pipeline run on a shared runner pool, with build caches doing the copy-on-write work of reusing unchanged artifacts. Nobody provisions a build system per branch, and nobody queues behind a single global build anymore.

The front end followed. On Vercel, every push to a non-production branch gets its own preview deployment by default; Netlify works the same way, and the branch itself is one immutable build plus routing on shared hosting infrastructure. Reviewers stopped asking whether a change works on someone’s laptop, because the change is already running somewhere.

Both cases have the same shape: the expensive machinery is shared, the branch is thin, and creating one is cheap enough that nobody thinks about it. That’s what a layer feels like once it has a branch primitive.

Each of these primitives also changed behavior once it arrived. Per-branch CI made it normal to run the full test suite on every push instead of nightly. Preview deploys made it normal for a product manager to click through a change before merge. Cheap branches don’t just remove a queue; they raise the bar for what gets checked before merge.

The data layer was supposed to be the hard case

Databases carry state, so conventional wisdom said branching would never work there. Then Neon, PlanetScale and Xata shipped it anyway, and Neon’s documentation now makes the parallel explicit: branch your data the same way you branch your code.

A database branch is a copy-on-write view over shared storage pages, created in seconds regardless of how large the database is. Schema migrations and risky data changes get validated against production-shaped data instead of a stale seed script, and the branch disappears when the work merges.

“If the layer with the most state can hand out branches in seconds, statelessness was never the real requirement.”

The data layer matters to this story because it removed the best excuse. If the layer with the most state can hand out branches in seconds, statelessness was never the real requirement. Whatever is still unbranched is unbranched by choice.

The runtime is the last layer to learn the trick

The microservices runtime resisted longest because it looks nothing like a file tree. It has live traffic, a service graph and dozens of moving dependencies, and the naive branch, a full copy of the environment, is so expensive that most teams concluded branching did not apply here.

The copy-on-write move works anyway. Run one shared, stable version of the system that is continuously deployed from main. For each change, deploy only the services the change touches as a lightweight ephemeral environment, and route each test request through the changed services while everything else falls through to the shared stable versions. The environment branch costs roughly what the changed services cost, which is why one can exist for every change an agent produces.

Routing is the part that sounds exotic and isn’t. A request tagged with a label gets steered to the changed service versions at each hop, propagated through the call chain the same way trace context already flows through most instrumented systems. The shared stable environment plays the role of main, the changed services are the delta, and the label is the pointer that assembles a coherent view of the system per request.

This isn’t a hypothetical architecture. Uber built SLATE to give each developer an ephemeral environment routed against shared production-grade dependencies because contention over staging could not keep up with its developer count.

Table showing each layer's shared stable resource and its corresponding delta

What an agent-native stack means

Put the layers together and a different development model appears. An agent picks up a task, and the change gets a worktree, a pipeline run, a preview, a data branch, and a running environment from the start. Validation stops being the scarce resource that serializes everything upstream of it.

Teams are already composing the lower layers. Bitso, a crypto exchange with 250-plus engineers, pairs an environment branch with a database branch for each change, so the runtime delta and the data delta travel together and shared staging stays out of the critical path.

That end-to-end branch is what the phrase agent-native software development lifecycle should mean. Not agents wired into yesterday’s pipeline, but a stack where any change, human or machine, can exist at every layer for as long as validation takes and disappear afterward.

The payoff compounds with agent count. When the branch primitive at every layer is a delta over something shared, validation concurrency scales with cluster capacity instead of with budget, and the number of changes a team can prove correct per day rises with the number it can generate. That is the ratio that decides whether agent adoption shows up as shipped software or as a longer queue.

The audit is cheap to run. Follow one change from worktree to validated and note the first layer where it waits on something shared. That’s where your stack stops branching. 

For most teams, the answer is the runtime, and if it’s yours, Signadot is a practical place to start.

The post Anthropic recommends a git worktree per agent. Your runtime infra makes that a problem. appeared first on The New Stack.

Read the whole story
alvinashcraft
30 minutes ago
reply
Pennsylvania, USA
Share this story
Delete

Figma Connect for WebStorm: Stage One of a Better Design-to-Code Experience

1 Share

Where time actually goes in design-to-code

Every design implementation starts the same way: find the Figma tab, find the right frame, screenshot it, paste it somewhere, switch back to the terminal. By the time you write the first line of code, you’ve already done five minutes of busywork, and you’ll do it again when you forget a value.

Your AI agent skips that ritual and goes straight to generating code. The problem: it has even less context than you did. It reaches for a custom button when you’re on shadcn/ui, or builds a dialog from scratch instead of using Base UI’s. You fix it in review, or after the designer flags it. Either way, you’re fixing something that didn’t need to happen.

The reason: Figma lives outside the IDE. The spec gets checked once, then abandoned. 

Figma Connect closes that gap. It brings your Figma file directly into WebStorm. The design is right there as you build, so you catch mismatches before they reach review.

Pass that context to AI to automate the tedious part of design-to-code. Your agent implements from the actual spec – reaching for the right shadcn/ui component, the correct Base UI primitive, or your internal design tokens – instead of approximating. You get the right component variants and hardcoded values from the get-go instead of having to fix them after the fact.

Figma Connect is supported in WebStorm 2026.2.1. Select any layer from your Figma file, get the full design context straight from the source via Figma MCP, and pass it to the AI chat. Your agent (Junie, Claude, Copilot, Cursor, etc.) or agentic IDE, like Air, generates code that fits your actual codebase, not a generic approximation. Figma Connect requires a one-time setup.

Get started

Work through a screen without leaving the IDE

When you’re building from someone else’s design, you want the right piece of that design in front of your agent without losing your place in the code. Here’s what usually gets in the way, and what Figma Connect for WebStorm does about it.

“I keep switching to Figma just to find the right frame.” The Layers list sits in the Figma Connect tool window. Expand it, click a layer, and the matching node is focused in Figma. Select something in Figma instead, and it appears under Currently selected with a thumbnail. Both directions, in real time.

“Every time I want to use a design, I’m copying a URL into the chat.” Your current selection is attached to every new AI chat automatically. Need something else, or several things at once? Add to Context, straight from the list.

“Attaching designs bloats my context.” Figma Connect attaches a reference to the node, its identifier, not a screenshot or the full design. Attach as often as you like.

A Forrester study found that developers with access to Figma design specs during implementation save over 90 minutes a week. Figma Connect makes that access automatic, without requiring you to switch tabs.

Better AI code generation as a bonus

When you pass the same design context to the AI chat, code generation improves, too. Your agent works from the actual spec instead of approximating your design system, which means less output needs to be corrected.

Figma Connect is supported in WebStorm 2026.2.1. Select any layer from your Figma file, get the full design context straight from the source via Figma MCP, and pass it to the AI chat. Your agent (Junie, Claude, Copilot, Cursor, etc.) or agentic IDE, like Air, generates code that fits your actual codebase, not a generic approximation.

Stage one of WebStorm’s design-to-code workflow optimization

The Figma MCP server and required configuration are set up and optimized automatically by WebStorm, you only need to set it up once (see how). Unlike when you add a generic MCP connection yourself, this integration is maintained with each WebStorm release. It works on day one.

Figma Connect is the first step in a larger workflow we’re building to keep design, code, and the running app visually aligned throughout the development lifecycle. The spec in your IDE is where that starts.
Thanks for choosing WebStorm. Stay tuned to see what’s coming next.

The WebStorm team

Read the whole story
alvinashcraft
30 minutes ago
reply
Pennsylvania, USA
Share this story
Delete

Adopting the Cross App Access Protocol: Get Ready for MCP Enterprise-Managed Authorization with Auth0

1 Share
Explore how to expose APIs and MCP servers to trusted AI agents using Auth0 and the Cross App Access (XAA) protocol.

Read the whole story
alvinashcraft
30 minutes ago
reply
Pennsylvania, USA
Share this story
Delete

Dark mode toggles: two states are enough

1 Share

A good two-state toggle can actually express all three data model states.

Until recently, if you looked at most websites with a theme toggle[1], you’d find three options: Light, Dark, and System.

Tailwind RetHat Design System Ant Web Awesome Excalidraw Taiga Astro Hero UI

Examples of tri-state dark mode toggles. In (LTR) reading direction: Ant Design, Red Hat Design System, Web Awesome, Excalidraw, Taiga, Astro, Hero UI.

Thankfully, these days the trend has shifted towards a simpler two-state toggle, but tri-state ones are still incredibly common.

Vitepress Material Spectrum Radix Shadcn

Examples of two-state dark mode toggles. In (LTR) reading direction: Vitepress, Material Design, Adobe Spectrum, Radix, ShadCN.

The rationale sounds plausible: “System” is a different intent than “Light” or “Dark”! One is a policy (whatever my OS says, do that) The other is a value (dark, forever, I don’t care what my OS says.) Surely, users should be able to express that intent!

Except, real users don’t generally seek out dark mode toggles to express intent for things to stay as they are, they seek them out when things need to change.

Think of the user goal when browsing a website (as opposed to a separate Settings page, where three states are fine). E.g. on a documentation site, they may be there to look something up. On a landing page, they may be trying to evaluate whether the product is suitable for their needs.
On a media site, they may be there to read the news.
On a graphics app, they want to draw something.

One thing is for certain: tweaking the theme is not their primary goal [2]. To get in the mindset of tweaking the theme, something needs to be off. When things look right, users just move on with their actual goal instead of thinking about the theme.

The tri-state control is solving a largely imaginary user goal that is extremely rare among real users, and does not justify the additional complication and UX friction of a three-state toggle.

Worse, it forces the user to decide between choices that produce no visible difference, breaking the principle of feedback.

Yes, tri-state toggles are common. That doesn’t make them good. This essay explains why, and how to do better.

Tri-state toggles are implementation-driven UI

One of the most common UX mistakes is designing UI around the underlying data model instead of user goals. Good interfaces abstract away the underlying model and expose a model that aligns with user goals (unless of course these happen to coincide, which is rare).

The user goal here is to set a specific flow and temperature. The underlying model works with amounts of hot and cold water. Guess which faucet is easier to use?

This is exactly the case with tri-state dark mode toggles; exposing all three states is data model leaking into the UI.

Yes, there should absolutely be three states in the underlying implementation! But at any given point, one of them is irrelevant to the end-user.

Users cannot meaningfully express intent about problems they don’t currently have.

A dark mode toggle is a temporary comfort adjustment. When it comes to user goals, there are only two real states:

  1. The website looks ok. The user moves on with their actual goal and doesn’t look for the toggle at all.
  2. The website is too bright or too dark to be comfortable. The user wants to fix it.

You’re reading in bed, the page is a flashbang, you hit the toggle. You’re on a laptop outside and the dark theme is unreadable in sunlight, you hit the toggle. It’s situational, it’s immediate, and it’s usually about the environment you’re in rather than a considered long-term stance on color schemes.

A third state assumes a usage scenario where a user visits a website that looks perfectly fine, and still looks for a dark mode toggle to ensure it can continue to look fine in the future. Users do all sorts of weird things, so I won’t assert that this never happens, but it is not a natural user interaction, fueled by a real user goal. Even the strongest proponents of tri-state toggles I have spoken with either admit they have never done this, or bring up some extremely rare, weird one-off edge cases.

But what’s the harm?

One could argue that sure, the third state is not frequently needed, but surely it doesn’t hurt to have it there for the one user that will need it, right?

But a more complex UI has a cost. It increases cognitive load for interacting with the control and forces you towards certain UI design decisions.

A two-state toggle can be very compact: Just a single icon that switches to another when clicked.

Some websites do go that route with a tri-state toggle that cycles through three states. Docusaurus for example:

Docusaurus header

But generally, the ergonomics of that are poorer than for the two state toggle, so it is no surprise it’s rare (Docusaurus was the only example I could find).

Some tri-state controls go for three icons side by side, which triples the screen real estate used.

RetHat Design System Hero UI Excalidraw Tailwind

Red Hat, Hero UI, Excalidraw, and Tailwind display three icons side by side.

Others, in an attempt to balance clarity and real estate, resort to a dropdown:

Ant Web Awesome Taiga Astro

Ant Design, Web Awesome, and Taiga UI go this route. Points for Web Awesome being the only one (!) to actually display what the system default resolves to.

That improves learnability, at the cost of efficiency, as it turns a single click interaction into a two-step process.

The actual perceived friction is actually worse than one extra click. Perceived friction is not a pure function of user actions, but also of the mental effort required to make a decision, and larger UI shifts (e.g. opening a dropdown) are more cognitively expensive than smaller ones (e.g. clicking a toggle) as the user needs to perceive and interpret a larger area.

If not three states, then what?

Guidance towards using tri-state controls is well meaning, but often based on paring good tri-state controls against poor two-state ones. E.g. in this article by Bramus:

Above that many implementations I have seen don’t take the “System” value into account. By omitting this option, the sites will never be able to respond to the system preference again, as they always have an override applied.

Indeed, a bad two state toggle is worse than a tri-state one. It makes the system mode unreachable once tweaked, making the selection irreversible and violating the usability principle of user control and freedom. A good two-state should be able to express all three states.

The idea is that the underlying model is still three states, but only two are shown at any given time:

Option Shown as Stored value
System default Current resolved value (e.g. sun or moon) None
Override Opposite of current resolved value (e.g. moon or sun) light or dark

When you press it for the first time, it toggles to the opposite of what you’re currently seeing, and stores the literal value (light or dark). The next time you press it, it toggles back to the system default, and removes the stored value.

That last bit is the one many two-state toggles get wrong. Storing a value that happens to match the system preference silently converts a temporary adjustment into a permanent pin with no way out.

Another common mistake is being overzealous about removing the stored value when the system preference changes, even if the user has explicitly set an override.

This evaluation must only happen at user interaction.

This is important because many users have their OS set to automatically switch between light and dark mode based on time of day, and removing the stored value proactively would make it impossible for them to actually pin a theme.

If a stored override later happens to coincide with the system preference — because the OS changed, not because the user did anything — you keep it.

This looks like an oversight — they’re the same now, why not tidy up? Because tidying up silently downgrades an explicit choice into a default, based on an event the user didn’t cause and can’t see.

Interactive demonstration

Here’s a concrete scenario that you can navigate interactively (view on separate page):

  1. Your OS is in light mode and the site has stored nothing, so the page follows along. Flip the OS control to run this the other way round.
  2. You toggle. The target is dark, which is not what the OS says, so the site stores an override. The page goes dark.
  3. Your OS switches to dark. The override now matches it but is still kept. Nothing visibly happens, which is correct.
  4. Your OS switches back to light. The page stays dark, because the override is still active.
  5. You toggle. The target is light, which is what the OS says, so the override is removed. The page follows the OS again.
  6. Your turn. Both controls are live and nothing from here on is scripted. Drive them in any order and watch what does — and does not — end up in localStorage.

But what if users get confused?

An argument I heard when discussing this was “but if the user selects light when their OS is light, then the OS switches to dark, won’t they get confused that the website did not preserve their choice?”

People hypothesizing that other people, who are not them, will get “confused” is a bit of a pet peeve of mine in usability discussions, but let’s entertain it for a moment.

Here’s that exact scenario:

  1. Your OS is in light mode and nothing is stored.
  2. You toggle to dark, which is stored as an override.
  3. You toggle again, meaning to pin light. It matches the OS, so the override is removed — you actually got the system default.
  4. Your OS switches to dark and the page follows. Not what you meant!
  5. But the fix is a single click: light no longer matches the OS, so this time it is an override, and thus pinned, so this can only happen at most once.

Remember, this control is entirely tangential to the actual user goal for visiting the website. Even if their intent were to pin light instead of reverting to System (light), this is something they would only notice once these diverge, i.e. the OS switches to dark. At that point, fixing it is a single click away. It’s such an easy fix, that there is no point in dwelling on it further.

It’s not that this never comes up, but making the tradeoff in favor of a tri-state control isn’t justifiable, IMO. A tri-state control introduces permanent UI complexity to prevent a one-time, easily fixable problem.

Additionally, color appearance is not just a pure function of color components, but also affected by surroundings and other factors. Even if a website implements only two modes, light mode may look slightly different in a light OS vs a dark OS, so selecting it as an override makes it an informed decision.

The title and icon could make the state clearer (e.g. the tooltip saying “Switch back to light (system default)” instead of “Switch to light” or the icon having a small screen icon instead of just a sun or moon). But those would need user testing to validate that they are an actual improvement. My concern is that once you distinguish System (light) from light, it (ironically) could become the thing that primes users to seek a third state that they previously had not considered.

Even if there is an ingenious UI that exposes three states at the same time without adding any cognitive load or friction (I have some ideas about what that might look like), I’m unconvinced this is a problem worth solving, and feels a lot like the UX version of premature optimization.

When is a tri-state control appropriate?

Although I spent the whole article arguing against tri-state toggles, there are actually valid use cases for them.

These are the two cases I’m aware of, but feel free to recommend more in the comments!

1. Color scheme setting that lives a separate settings panel

This article is primarily geared towards a permanently visible toggle in the header or footer.

A setting that lives alongside other settings in a settings panel is a fundamentally different usage scenario:

  • The user is already in the mode of making decisions about their future
  • The expectation is not that every setting must produce immediate feedback
  • There is a lot more screen real estate to explain three states.

It is no accident that while 2-state toggles are becoming the norm for persistent controls, tri-state is (rightly) king for settings panels.

Bluesky settings panel

Bluesky’s Appearance settings panel. The tri-state is fine here. Showing the “Dark mode” option below even when it produces no effect, on the other hand…

Google Calendar settings panel

Google Calendar. Love the icons, it would be nice to actually indicate what System currently resolves to.

Twitter/X settings panel

I’m not one to praise post-X Twitter, but having two two-state toggles instead of one tri-state is a very interesting design choice. The UX is not quite there, but if done well, I think it could be the best of both worlds when you have the screen real estate.

2. When color schemes are implemented differently depending on the system setting

This entire essay assumes the common case where a website only has two color schemes: light and dark, and there is no difference between light mode in a dark OS vs light mode in a light OS. Vadim Makeev had an interesting idea: color schemes should take the underlying OS setting into account. Light mode should be less bright in a dark OS and dark mode should be less dark in a light OS, to reduce the contrast between the website and the rest of the system.

I have not seen many UIs doing this, and CSS does not make it easier (light-dark() is very much designed around duality), but if you are actually doing this, you have earned your three states my friend, display them as prominently as you like, none of this applies to you!

The general version

The dark mode toggle is a nice case study, but the underlying lesson is bigger:

Users do not seek out solutions to problems they don’t currently have.

The tri-state toggle is the GUI version of low signal-to-noise APIs that ask you to pass dozens of parameters that could have sensible defaults, forcing you to decide on problems you have not encountered and are not relevant.

Do not flood users with options that are irrelevant to their current situation. Options that might become relevant in the future, should be surfaced in that future, not pre-emptively.

Not every state of your state machine warrants visible UI.

Ultimately, everything boils down to the very same principle:
Respect user effort.

Thanks to Chris Lilley and Jake Archibald for reviewing an earlier version of this draft


  1. Unless otherwise noted, this refers to a permanently visible toggle in the header or (rarely) footer, not a theme setting in a separate settings panel. ↩︎

  2. This is about users. Yes, the developers of the site may have a goal of testing the theme, but we optimize UIs for being used, not getting debugged. ↩︎

Read the whole story
alvinashcraft
30 minutes ago
reply
Pennsylvania, USA
Share this story
Delete

Database Animations: Why Big Columns May Not Affect Logical Reads

1 Share

Over the years, tables – like your waistline – tend to get bigger. We keep tacking on more and more columns, one at a time, in order to handle app needs. It’s easier to add “just one more column” than it is to break things off into a whole separate table.

When you’re only handling a few rows at a time, like transactional insert/update/deletes and one-row selects, the overhead of these additional columns isn’t a big deal. SQL Server can dive into that one row and just fetch it, and since it sits on a single 8KB page anyway, the number of columns doesn’t affect single-row operations.

However, when you need to read multiple rows, the more rows you need to read, the more these extra columns will affect the overhead of the operation. This kinda thing is best illustrated with one of my Database Animations showing the difference between an index that only has Id and DisplayName, versus one that includes a bunch of wider string columns:

Wide Rows Seek Vs Scan (animation)
▶ Watch the animated version of Wide Rows Seek Vs Scan

I am amused by the AI’s final comment: “wide columns ride free on seeks.” Alrighty then. That certainly sounds like something I’d say. (I use Claude Code to build these animations: we storyboard them out together, and then it handles the details, and surprises me with little tidbits like that.)

As your rows get larger and larger – either due to more columns, or wider columns like JSON and XML, or both – SQL Server is forced to keep an eye on each row’s length. If the row can’t fit on an 8KB page, SQL Server automatically moves that data off-row.

As long as you’re not touching that off-row column – like if you’re not selecting or updating it – then the off-row column doesn’t impact the number of reads you need to do. That’s pretty cool, and it means that I don’t mind if people just store JSON data without manipulating it, and they only fetch it when they need it.

If you wanna be proactive, and if you’re sure that most operations don’t need those large columns, you can even tell SQL Server that you want large columns stored off-row by default, even when the row sizes are small. Check out sp_tableoption:

EXECUTE sp_tableoption 'dbo.Users', 'large value types out of row', 1;

Let’s get animated. On the left side, we have a table where all the wide columns stay on-row, and on the right, we’ve used sp_tableoption to force them all off-row, onto their own pages linked by a pointer:

Off Row Storage (animation)
▶ Watch the animated version of Off Row Storage

As long as you’re not selecting *, this option makes more sense, especially for big string columns like JSON, XML, and (N)VARCHAR(MAX) that you only grab when you’re pulling specific individual rows out of the database. The Users.AboutMe column is a great example: we ain’t running reports on AboutMe contents, nor using it for filtering, just outputting it when rendering a specific user’s profile page.

That sp_tableoption setting only takes effect on newly inserted/updated rows. If you want it to apply to the stuff that’s already in the tables, you’ll need to do an index rebuild.

Read the whole story
alvinashcraft
31 minutes ago
reply
Pennsylvania, USA
Share this story
Delete
Next Page of Stories