Web Standards

Defining Chat Apps

LukeW - 20 hours 15 min ago

With each new technology platform shift, what defines a software application changes dramatically. While we're still in the midst of the AI shift, there's emergent properties that seem to be shaping what at least a subset of AI applications, let's call them chat apps, might look like going forward.

At a high level, applications are defined by the systems they're discovered and operated in. This frames what capabilities they can utilize, their primary inputs, outputs, and more. That sounds abstract so let's make it concrete. Applications during the PC era were compiled binaries sold as shrink-wrapped software that used local compute and storage, monitors as output, and the mouse and keyboard as input.

These capabilities defined not only their interfaces (GUI) but their abilities as well. The same is true for applications born of the AI era. How they're discovered and where they operate will also define them. And that's particularly true of "chat apps".

So what's a chat app? Most importantly a chat app's compute engine is an AI model which means all the capabilities of the model also become capabilities of the app. If the model can translate from one language to another, the app can. If a model can generate PDF files, the app can. It's worth noting that "model" could be a combination of AI models (with routing), prompts and tools. But to an end user, it would appear as a singular entity like ChatGPT or Claude.

When an application runs in Claude or ChatGPT, it's like running in an OS (windows during the PC era, iOS during the mobile era). So how do you run a chat app in an AI model like Claude and what happens when you do? Today an application can be added to Claude as a "connector" probably running as a remote Model Context Protocol (MCP) server. The process involves some clicking through forms and dialog boxes but once setup, Claude has the ability to use the application on behalf of the person that added it.

As mentioned above, the capabilities of Claude are now the capabilities of the app. Claude can accept natural language as input, so can the app. When people upload an image to Claude, it understands its content, so does the app. Claude can search and browse the Web, so can the app. The same is true for output. If Claude can turn information into a PDF, so can the app. If Claude can add information to Salesforce, so can the app. You get the idea.

So what's left for the application to do? If the AI model provides input, output, and processing capabilities, what does a chat app do? In it's simplest form a chat app can be a database that stores the information the app uses for input, output, and processing and a set of dynamic instructions for the AI model how to use it.

As always, a tangible example makes this clear. Let's say I want to make a chat app for tracking the concerts I'm attending. Using a service like AgentDB, I can start with an existing file of concerts I'm tracking or ask a model to create one. I then have a remote MCP server backed by a database of concert information and a dynamic template that continually instructs an AI model on to use it.

When I add that remote MCP link to Claude, I can: upload screenshots of upcoming concerts to track using Claude's image parsing ability); generate a calendar view of all my concerts (using Claude's coding ability); find additional information about an upcoming show (using Claude's Web search tools); and so on. All of these capabilities of the Claude "model" work with my database plus template, aka my chat app.

You can make your own chat apps instantly by using AgentDB to create a remote MCP link and adding it to Claude as a connector. It's not a very intuitive process today (see above) but will very likely feel as simple as using a mobile app store in the not too distant future. At which point, chat apps will probably proliferate.

What Can We Actually Do With corner-shape?

Css Tricks - Fri, 09/12/2025 - 4:20am

When I first started messing around with code, rounded corners required five background images or an image sprite likely created in Photoshop, so when border-radius came onto the scene, I remember everybody thinking that it was the best thing ever. Web designs were very square at the time, so to have border-radius was super cool, and it saved us a lot of time, too.

Chris’ border-radius article from 2009, which at the time of writing is 16 years old (wait, how old am I?!), includes vendor prefixes for older web browsers, including “old Konqueror browsers” (-khtml-border-radius). What a time to be alive!

We’re much less excited about rounded corners nowadays. In fact, sharp corners have made a comeback and are just as popular now, as are squircles (square-ish circles or circle-y squares, take your pick), which is exactly what the corner-shape CSS property enables us to create (in addition to many other cool UI effects that I’ll be walking you through today).

At the time of writing, only Chrome 139 and above supports corner-shape, which must be used with the border-radius property or/and any of the related individual properties (i.e., border-top-left-radius, border-top-right-radius, border-bottom-right-radius, and border-bottom-left-radius):

CodePen Embed Fallback Snipped corners using corner-shape: bevel

These snipped corners are becoming more and more popular as UI designers embrace brutalist aesthetics.

In the example above, it’s as easy as using corner-shape: bevel for the snipped corners effect and then border-bottom-right-radius: 16px for the size.

corner-shape: bevel; border-bottom-right-radius: 16px;

We can do the same thing and it really works with a cyberpunk aesthetic:

CodePen Embed Fallback Slanted sections using corner-shape: bevel

Slanted sections is a visual effect that’s even more popular, probably not going anywhere, and again, helps elements to look a lot less like the boxes that they are.

Before we dive in though, it’s important to keep in mind that each border radii has two semi-major axes, a horizontal axis and a vertical axis, with a ‘point’ (to use vector terminology) on each axis. In the example above, both are set to 16px, so both points move along their respective axis by that amount, away from their corner of course, and then the beveled line is drawn between them. In the slanted section example below, however, we need to supply a different point value for each axis, like this:

corner-shape: bevel; border-bottom-right-radius: 100% 50px; CodePen Embed Fallback

The first point moves along 100% of the horizontal axis whereas the second point travels 50px of the vertical axis, and then the beveled line is drawn between them, creating the slant that you see above.

By the way, having different values for each axis and border radius is exactly how those cool border radius blobs are made.

Sale tags using corner-shape: round bevel bevel round

You’ve see those sale tags on almost every e-commerce website, either as images or with rounded corners and not the pointy part (other techniques just aren’t worth the trouble). But now we can carve out the proper shape using two different types of corner-shape at once, as well as a whole set of border radius values:

CodePen Embed Fallback

You’ll need corner-shape: round bevel bevel round to start off. The order flows clockwise, starting from the top-left, as follows:

  • top-left
  • top-right
  • bottom-right
  • bottom-left

Just like with border-radius. You can omit some values, causing them to be inferred from other values, but both the inference logic and resulting value syntax lack clarity, so I’d just avoid this, especially since we’re about to explore a more complex border-radius:

corner-shape: round bevel bevel round; border-radius: 16px 48px 48px 16px / 16px 50% 50% 16px;

Left of the forward slash (/) we have the horizontal-axis values of each corner in the order mentioned above, and on the right of the /, the vertical-axis values. So, to be clear, the first and fifth values correspond to the same corner, as do the second and sixth, and so on. You can unpack the shorthand if it’s easier to read:

border-top-left-radius: 16px; border-top-right-radius: 48px 50%; border-bottom-right-radius: 48px 50%; border-bottom-left-radius: 16px;

Up until now, we’ve not really needed to fully understand the border radius syntax. But now that we have corner-shape, it’s definitely worth doing so.

As for the actual values, 16px corresponds to the round corners (this one’s easy to understand) while the 48px 50% values are for the bevel ones, meaning that the corners are ‘drawn’ from 48px horizontally to 50% vertically, which is why and how they head into a point.

Regarding borders — yes, the pointy parts would look nicer if they were slightly rounded, but using borders and outlines on these elements yields unpredictable (but I suspect intended) results due to how browsers draw the corners, which sucks.

Arrow crumbs using the same method

Yep, same thing.

CodePen Embed Fallback

We essentially have a grid row with negative margins, but because we can’t create ‘inset’ arrows or use borders/outlines, we have to create an effect where the fake borders of certain arrows bleed into the next. This is done by nesting the exact same shape in the arrows and then applying something to the effect of padding-right: 3px, where 3px is the value of the would-be border. The code comments below should explain it in more detail (the complete code in the Pen is quite interesting, though):

<nav> <ol> <li> <a>Step 1</a> </li> <li> <a>Step 2</a> </li> <li> <a>Step 3</a> </li> </ol> </nav> ol { /* Clip n’ round */ overflow: clip; border-radius: 16px; li { /* Arrow color */ background: hsl(270 100% 30%); /* Reverses the z-indexes, making the arrows stack */ /* Result: 2, 1, 0, ... (sibling-x requires Chrome 138+) */ z-index: calc((sibling-index() * -1) + sibling-count()); &:not(:last-child) { /* Arrow width */ padding-right: 3px; /* Arrow shape */ corner-shape: bevel; border-radius: 0 32px 32px 0 / 0 50% 50% 0; /* Pull the next one into this one */ margin-right: -32px; } a { /* Same shape */ corner-shape: inherit; border-radius: inherit; /* Overlay background */ background: hsl(270 100% 50%); } } } Tooltips using corner-shape: scoop CodePen Embed Fallback

To create this tooltip style, I’ve used a popover, anchor positioning (to position the caret relative to the tooltip), and corner-shape: scoop. The caret shape is the same as the arrow shape used in the examples above, so feel free to switch scoop to bevel if you prefer the classic triangle tooltips.

A quick walkthrough:

<!-- Connect button to tooltip --> <button popovertarget="tooltip" id="button">Click for tip</button> <!-- Anchor tooltip to button --> <div anchor="button" id="tooltip" popover>Don’t eat yellow snow</div> #tooltip { /* Define anchor */ anchor-name: --tooltip; /* Necessary reset */ margin: 0; /* Center vertically */ align-self: anchor-center; /* Pin to right side + 15 */ left: calc(anchor(right) + 15px); &::after { /* Create caret */ content: ""; width: 5px; height: 10px; corner-shape: scoop; border-top-left-radius: 100% 50%; border-bottom-left-radius: 100% 50%; /* Anchor to tooltip */ position-anchor: --tooltip; /* Center vertically */ align-self: anchor-center; /* Pin to left side */ right: anchor(left); /* Popovers have this already (required otherwise) */ position: fixed; } }

If you’d rather these were hover-triggered, the upcoming Interest Invoker API is what you’re looking for.

Realistic highlighting using corner-shape: squircle bevel

The <mark> element, used for semantic highlighting, defaults with a yellow background, but it doesn’t exactly create a highlighter effect. By adding the following two lines of CSS, which admittedly I discovered by experimenting with completely random values, we can make it look more like a hand-waved highlight:

mark { /* A...squevel? */ corner-shape: squircle bevel; border-radius: 50% / 1.1rem 0.5rem 0.9rem 0.7rem; /* Prevents background-break when wrapping */ box-decoration-break: clone; } CodePen Embed Fallback

We can also use squircle by itself to create those fancy-rounded app icons, or use them on buttons/cards/form controls/etc. if you think the ‘old’ border radius is starting to look a bit stale:

CodePen Embed Fallback CodePen Embed Fallback Hand-drawn boxes using the same method

Same thing, only larger. Kind of looks like a hand-drawn box?

CodePen Embed Fallback

Admittedly, this effect doesn’t look as awesome on a larger scale, so if you’re really looking to wow and create something more akin to the Red Dead Redemption aesthetic, this border-image approach would be better.

Clip a background with corner-shape: notch

Notched border radii are ugly and I won’t hear otherwise. I don’t think you’ll want to use them to create a visual effect, but I’ve learned that they’re useful for background clipping if you set the irrelevant axis to 50% and the axis of the side that you want to clip by the amount that you want to clip it by. So if you wanted to clip 30px off the background from the left for example, you’d choose 30px for the horizontal axes and 50% for the vertical axes (for the -left-radius properties only, of course).

corner-shape: notch; border-top-left-radius: 30px 50%; border-bottom-left-radius: 30px 50%; CodePen Embed Fallback Conclusion

So, corner-shape is actually a helluva lot of fun. It certainly has more uses than I expected, and no doubt with some experimentation you’ll come up with some more. With that in mind, I’ll leave it to you CSS-Tricksters to mess around with (remember though, you’ll need to be using Chrome 139 or higher).

As a parting gift, I leave you with this very cool but completely useless CSS Tie Fighter, made with corner-shape and anchor positioning:

CodePen Embed Fallback

What Can We Actually Do With corner-shape? originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Letting the Machines Learn

LukeW - Thu, 09/11/2025 - 2:00pm

Every time I present on AI product design, I'm asked about AI and intellectual property. Specifically: aren't you worried about AI models "stealing" your work? I always answer that if I accused AI models of theft, I'd have to accuse myself as well. Let me explain…

I've spent 30 years writing three books and over two thousand articles on digital product design and strategy. But during those same 30 years? I've consumed exponentially more. Countless books, articles, tweets. Thousands of conversations. Products I've used, solutions I've analyzed. All of it shaped what I know and how I write.

If you asked me to trace the next sentence I type back to its sources, to properly attribute the influences that led to those specific words, I couldn't do it. The synthesis happens at a level I can't fully decompose.

AI models are doing what we do. Reading, viewing, learning, synthesizing. The only difference is scale. They process vastly more information than any human could. When they generate text, they're drawing from that accumulated knowledge. Sound familiar?

So when an AI model produces something influenced by my writings, how is that different from a designer who read my book and applies those principles? I put my books out there for people to buy and learn from. My articles? Free for anyone to read. Why should machines be excluded from that learning opportunity?

"But won't AI companies unfairly profit from training on your content?"

From AI model companies, for $20 per month, I get an assistant that's read more than I ever could, available instantly, capable of helping with everything from code reviews to strategic analysis. That same $20 couldn't buy me two hours of entry-level human assistance.

The benefit I receive from these models, trained on the collective knowledge of millions of contributors, including my microscopic contribution, dwarfs any hypothetical loss from my content being training data. In fact, I'm humbled that my thoughts could even be part of a knowledge base used by billions of people.

So let machines learn, just like humans do. For me, the value I get back from well-trained AI models far exceeds what my contribution puts in.

Compiling Multiple CSS Files into One

Css Tricks - Thu, 09/11/2025 - 5:16am

Stu Robson is on a mission to “un-Sass” his CSS. I see articles like this pop up every year, and for good reason as CSS has grown so many new legs in recent years. So much so that much of the core features that may have prompted you to reach for Sass in the past are now baked directly into CSS. In fact, we have Jeff Bridgforth on tap with a related article next week.

What I like about Stu’s stab at this is that it’s an ongoing journey rather than a wholesale switch. In fact, he’s out with a new post that pokes specifically at compiling multiple CSS files into a single file. Splitting and organizing styles into separate files is definitely the reason I continue to Sass-ify my work. I love being able to find exactly what I need in a specific file and updating it without having to dig through a monolith of style rules.

But is that a real reason to keep using Sass? I’ve honestly never questioned it, perhaps due to a lizard brain that doesn’t care as long as something continues to work. Oh, I want partialized style files? Always done that with a Sass-y toolchain that hasn’t let me down yet. I know, not the most proactive path.

Stu outlines two ways to compile multiple CSS files when you aren’t relying on Sass for it:

Using PostCSS

Ah, that’s right, we can use PostCSS both with and without Sass. It’s easy to forget that PostCSS and Sass are compatible, but not dependent on one another.

postcss main.css -o output.css

Stu explains why this could be a nice way to toe-dip into un-Sass’ing your work:

PostCSS can seamlessly integrate with popular build tools like webpack, Gulp, and Rollup, allowing you to incorporate CSS compilation into your existing development workflow without potential, additional configuration headaches.

Custom Script for Compilation

The ultimate thing would be eliminating the need for any dependencies. Stu has a custom Node.js script for that:

const fs = require('fs'); const path = require('path'); // Function to read and compile CSS function compileCSS(inputFile, outputFile) { const cssContent = fs.readFileSync(inputFile, 'utf-8'); const imports = cssContent.match(/@import\s+['"]([^'"]+)['"]/g) || []; let compiledCSS = ''; // Read and append each imported CSS file imports.forEach(importStatement => { const filePath = importStatement.match(/['"]([^'"]+)['"]/)[1]; const fullPath = path.resolve(path.dirname(inputFile), filePath); compiledCSS += fs.readFileSync(fullPath, 'utf-8') + '\n'; }); // Write the compiled CSS to the output file fs.writeFileSync(outputFile, compiledCSS.trim()); console.log(`Compiled CSS written to ${outputFile}`); } // Usage const inputCSSFile = 'index.css'; // Your main CSS file const outputCSSFile = 'output.css'; // Output file compileCSS(inputCSSFile, outputCSSFile);

Not 100% free of dependencies, but geez, what a nice way to reduce the overhead and still combine files:

node compile-css.js

This approach is designed for a flat file directory. If you’re like me and prefer nested subfolders:

With the flat file structure and single-level import strategy I employ, nested imports (you can do with postcss-import aren’t necessary for my project setup, simplifying the compilation process while maintaining clean organisation.

Very cool, thanks Stu! And check out the full post because there’s a lot of helpful context behind this, particularly with the custom script.

Compiling Multiple CSS Files into One originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

What’re Your Top 4 CSS Properties?

Css Tricks - Wed, 09/10/2025 - 3:13am

That’s what Donnie D’Amato asks in a recent post:

You are asked to build a website but you can use only 4 CSS properties, what are those?

This really got the CSS-Tricks team talking. It’s the nerdy version of “if you could only take one album with you on a remote island…” And everyone had a different opinion which is great because it demonstrates the messy, non-linear craft that is thinking like a front-end developer.

Seems like a pretty straightforward thing to answer, right? But like Donnie says, this takes some strategy. Like, say spacing is high on your priority list. Are you going to use margin? padding? Perhaps you’re leaning into layout and go with gap as part of a flexbox direction… but then you’re committing to display as one of your options. That can quickly eat up your choices!

Our answers are pretty consistent, but converged even more as the discussion wore on and all of us were coming at it with different priorities. I’ll share each person’s “gut” reaction because I like how raw it is. I think you’ll see that there’s always a compromise in the mix, but those compromises really reveal a person’s cards as far as what they think is most important in a situation with overly tight constraints.

Juan Diego Rodriguez

Juan and I came out pretty close to the same choices, as we’ll see in a bit:

  • font: Typography is a priority and we get a lot of constituent properties with this single shorthand.
  • padding: A little padding makes things breath and helps with visual separation.
  • background: Another shorthand with lots of styling possibilities in a tiny package.
  • color: More visual hierarchy.

But he was debating with himself a bit in the process:

Thinking about switching color with place-items, since it works in block elements. grid would need display, though).

Ryan Trimble

Ryan’s all about that bass structure:

  • display: This opens up a world of layouts, but most importantly flex.
  • flex-direction: It’s a good idea to consider multi-directional layouts that are easily adjustable with media queries.
  • width: This helps constrain elements and text, as well as divide up flex containers.
  • margin: This is for spacing that’s bit more versatile than gap, while also allowing us to center elements easily.

And Ryan couldn’t resist reaching a little out of bounds:

For automatic color theme support, and no extra CSS properties required: <meta name="color-scheme" content="dark light"> 

Danny Schwarz

Every team needs a wild card:

On the contrary I think I’d choose font, padding, and color. I wouldn’t even choose a 4th.

  • font: This isn’t a big surprise if you’re familiar with Danny’s writing.
  • padding: So far, Ryan’s the only one to eschew padding as a core choice!
  • color: Too bad this isn’t baked right into font!

I’ll also point out that Danny soon questioned his decision to use all four choices:

I supposed we’d need width to achieve a good line length.

Sunkanmi Fafowora

This is the first list to lean squarely into CSS Grid, allowing the grid shorthand to take up a choice in favor of having a complete layout system:

  • font: This is a popular one, right?
  • display: Makes grid available
  • grid: Required for this display approach
  • color: For sprinkling in text color where it might help

I love that Ryan and Sunkanmi are thinking in terms of structure, albeit in very different ways for different reasons!

Zell Liew

In Zell’s own words: “Really really plain and simple site here.”

  • font: Content is still the most important piece of information.
  • max-width: Ensures type measure is ok.
  • margin: Lets me play around with spacing.
  • color: This ensures there’s no pure black/white contrast that hurts the eyes. I’d love for background as well, but we only have four choices.

But there’s a little bit of nuance in those choices, as he explains: “But I’d switch up color for background on sites with more complex info that requires proper sectioning. In that case I’d also switch margin with padding.”

Amit Sheen

Getting straight to Amit’s selections:

  • font
  • color
  • background
  • color-scheme

The choices are largely driven by wanting to combat default user agent styles:

The thing is, if we only have four properties, we end up relying heavily on the user agents, and the only thing I’d really want to change is the fonts. But while we are at it, let’s add some color control. I’m not sure how much I’d actually use them, but it would be good to have them available.

Geoff Graham

Alright, I’m not quite as exciting now that you’ve seen everyone else’s choices. You’ll see a lot of overlap here:

  • font: A shorthand for a whopping SEVEN properties for massaging text styles.
  • color: Seems like this would come in super handy for establishing a visual hierarchy and distinguishing one element from another.
  • padding: I can’t live without a little breathing room between an element’s content box and its inner edge.
  • color-scheme: Good minimal theming that’ll work nicely alongside color and support the light-dark() function.

Clearly, I’m all in on typography. That could be a very good thing or it could really constrain me when it comes to laying things out. I really had to fight the urge to use display because I always find it incredibly useful for laying things out side-by-side that wouldn’t otherwise be possible with block-level elements.

Your turn!

Curious minds want to know! Which four properties would you take with you on a desert island?

What’re Your Top 4 CSS Properties? originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Unstructured Input in AI Apps Instead of Web Forms

LukeW - Mon, 09/08/2025 - 2:00pm

Web forms exist to put information from people into databases. The input fields and formatting rules in online forms are there to make sure the information fits the structure a database needs. But unstructured input in AI-enabled applications means machines, instead of humans, can do this work.

17 years ago, I wrote a book on Web Form Design that started with "Forms suck." Fast forward to today and the sentiment still holds true. No one likes filling in forms but forms remain ubiquitous because they force people to provide information in the way it's stored within the database of an application. You know the drill: First Name, Last Name, Address Line 2, State abbreviation, and so on.

With Web forms, the burden is on people to adapt to databases. Today's AI models, however, can flip this requirement. That is, they allow people to provide information in whatever form they like and use AI do the work necessary to put that information into the right structure for a database.

How does this work? Instead of a Web form enforcing the database's input requirements a dynamic context system can handle it. One way of doing this is with AgentDB's templating system, which provides instructions to AI models for reading and writing information to a database.

With AgentDB connected to an AI model (via an MCP server), a person can simply say "add this" and provide an image, PDF, audio, video, you name it. The model will use AgentDB's template to decide what information to extract from this unstructured input and how to format it for the database. In the case where something is missing or incomplete, the model can ask for clarification or use tools (like search) to find possible answers.

In the example above, I upload a screenshot from Instagram announcing a concert and ask the AI model to add it to my concert tracker. The AgentDB template tells the model it needs Show, Date, Venue, City, Time, and Ticket Price for each database entry. So the AI model pulls this information from the unstructured input (screenshot) and, if complete, turns it into the structured format a database needs.

Of course, the unstructured input can also be a photo, a link to a Web page, a Word document, a PDF file, or even just audio where you say what you want to add. In each case the combination of AI model and AgentDB will fill in the database for you.

No Web form required. And no form is the best kind of Web Form Design.

What You Need to Know About CSS Color Interpolation

Css Tricks - Fri, 09/05/2025 - 3:44am

Color interpolation, loosely speaking, is the process of determining the colors between two color points. It allows us to create unique colors, beautiful palettes, better gradients, and smooth transitions.

I recently wrote a Guide to CSS Color Functions but didn’t have the chance to explain color interpolation in any great depth — which is a shame, since it allows us to create cool demos like this one:

CodePen Embed Fallback

Did you notice how oklch(80% 0.3 340) interpolates to oklch(80% 0.3 60), then to oklch(80% 0.3 180), then to oklch(80% 0.3 270) and back to oklch(80% 0.3 340) using CSS animation? Well, I did! And that’s just a powerful use of interpolation.

Where can we use color interpolation?

Again, color interpolation is all over CSS. These properties and functions support color interpolation either through direct mixing, gradients, or transitions:

In gradients and the color-mix() function, we even have a formal syntax for color interpolation:

<color-interpolation-method> = in [ <rectangular-color-space> | <polar-color-space> <hue-interpolation-method>? ] <color-space> = <rectangular-color-space> | <polar-color-space> <rectangular-color-space> = srgb | srgb-linear | display-p3 | a98-rgb | prophoto-rgb | rec2020 | lab | oklab | xyz | xyz-d50 | xyz-d65 <polar-color-space> = hsl | hwb | lch | oklch <hue-interpolation-method> = [ shorter | longer | increasing | decreasing ] hue

Yes, that’s a convoluted definition, but if we go ahead and inspect how this syntax works in color-mix(), for example, we would have something like this:

.element{ color: color-mix(in lch longer hue, red, blue); }

The CSS color-mix() function provides a way for us to mix different colors in any color space, which is all what color interpolation is about: going from color to another.

Our key focus is the in lab longer hue part, which specifies how color-mix() does the interpolation. This is basically saying, “Hey CSS, interpolate the next colors in the CIELCH color space using a longer hue arc.” Yes, the in lab part means the interpolation is done in CIELCH, one of the many CSS color spaces, but we’ll get to what longer hue exactly means later.

Just remember:

  • The in keyword always precedes the color interpolation method.
  • The second value is the color space used for mixing.
  • The third value is an optional hue interpolation method ending with the hue keyword.

This same syntax appears in all gradient functions, where colors are interpolated gradually to get a smooth gradient. Look at how tweaking the gradient with the color interpolation syntax can give us a completely new gradient:

.element { background: linear-gradient(in oklch longer hue 90deg, magenta, cyan); } CodePen Embed Fallback

Let’s backtrack a little, though. Interpolation can occur in two major color spaces: rectangular and polar.

Rectangular color spaces

Rectangular color spaces represent colors using Cartesian coordinates on a three-dimensional plane, which you might already know as the X (horizontal), Y (vertical), and Z (depth) axes on a graph.

Rectangular color spaces are like the same sort of graph, but is a map of color points instead. For example, the sRGB color space has three axes, responsible for the amount of a color’s redness, blueness, and greenness.

Polar color spaces

Polar color spaces also represent colors in a three-dimensional plane, just like rectangular color spaces, but it is shaped like a cylinder instead of a rectangular. A color point is represented by three values:

  • The height from the point to the center, usually assigned to lightness or brightness.
  • The radial distance from the center, usually assigned to chroma or saturation.
  • The angle around the center, assigned to the hue.
Credit: Wikipedia

What makes polar color spaces unique is the hue angle. Since it’s an angle, and they are cyclic (like a continuous circle), we have more options for how it can be interpolated.

Hue interpolation

Think of hue interpolation like finding the distance between the two times on a clock.

Let’s assume the clock can go clockwise (forwards) or counterclockwise (backwards) in time.

The minute hand is at 10 minutes (2). If we want to take the shortest distance between 50 minutes (10), then we would make a counterclockwise turn, like going back in time since that is shorter than moving forward in a clockwise direction.

That’s because if you take the longer route, you’ll have to pass through 3, 4, 5, etc. all the way to 10. Taking the shorter counterclockwise) route , you would reach 10 in less time (15 minutes).

Hue interpolation works similarly. It is a CSS algorithm that determines how you want hue colors in polar color spaces to be mixed, and the direction you want to take between two hue points.

There are four types of hue interpolation in CSS. Let’s go over those next.

shorter and longer

The shorter (default value) hue interpolation method simply takes the shorter route, while the longer hue interpolation method takes the longer route when mixing colors between two hue points.

Imagine blending two hue values red (0deg) and blue (240deg). There are two ways to do this:

  • Go the longer route (distance of 240deg).
  • Go the shorter route (distance of 120deg).

If shorter is used, the browser takes the shorter route (120deg). Otherwise, if longer is used, the browser takes the longer route (240deg).

CodePen Embed Fallback

This offers up a nice and unique blend of colors depending on your preferences. Hue interpolation is useful in creating smooth color transitions and gradients, giving plenty of life to the websites using color.

The shorter or longer hue interpolation, depending on the shortest or longest distances between two hue value points, can either go clockwise or counterclockwise. We can also set this automatically without actually using one of these keywords, which we will look at next.

increasing and decreasing

Sticking with our clock analogy, the increasing hue interpolation method is like moving the minutes hand from 2 to 10, always in a clockwise direction. Even if the final value is 1, it would still go in a clockwise direction, doing almost a full turn.

If, however, the hue interpolation method is set to decreasing, the minutes hand will always go in a counterclockwise direction. As the specification says, “[d]epending on the difference between the two angles, this will either look the same as shorter or as longer.”

If the angle goes from 20deg to 50deg using the increasing hue interpolation value, the value will move clockwise from 20deg to 50deg, displaying the colors in between. However, if the hue interpolation method is set to decreasing, then the algorithm takes the value from 20deg to 50deg in a counterclockwise direction.

Since increasing means the clock’s minute hand is constantly moving forward, this means the value can reach up to 360deg, a full circle. If the angle reaches 360deg, it resets back to 0deg until it reaches the next point. But if decreasing reaches 0deg, then it resets to 360deg, keeping the hue change consistent.

CodePen Embed Fallback How is this useful?

Yes, all this theory is great: we can use interpolation to get the intermediary color(s) between two colors and make new kinds of colors, but how can we actually use it to create better color experiences in CSS?

Creating gradients

Color interpolation happens frequently in all CSS gradient functions. Take, for example, the conic-gradient() function, which makes it easy to create a smooth transition of colors that rotate around a center point:

background: conic-gradient( from 0deg, oklch(70% 0.3 0deg), oklch(70% 0.3 120deg), oklch(70% 0.3 240deg), oklch(70% 0.3 360deg) ); CodePen Embed Fallback

Notice how the hue blends smoothly between each color stop point? It’s beautiful.

Color mixing

Reading about color-mix() in the CSS-Tricks Almanac will give you a basic idea of how this is done, but if you’re like me and want the raw code, here it is:

/* First Box */ background-color: color-mix(in oklch, rgb(255 0 0) 50%, lch(60% 40% 220deg) 50%); /* Second Box */ background-color: color-mix(in oklch longer hue, rgb(255 0 0) 50%, lch(60% 40% 220deg) 50%); CodePen Embed Fallback

A great advantage of color-mix() is that you gain the ability to mix colors in different color spaces within another color space, thereby producing a unique color. Again, it’s moving from one color into another and the direction we take for mixing colors matters.

Animation

We can animate the transition between colors! So, instead of mixing two specific points, we can watch the color transition between all of the colors in between the two points!

@keyframes bg-shift { from { background-color: oklch(30% 0.3 20deg); /* dark pink */ } to { background-color: oklch(70% 0.3 200deg); /* Cool bluish */ } } CodePen Embed Fallback References

What You Need to Know About CSS Color Interpolation originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

World Knowledge Improves AI Apps

LukeW - Tue, 09/02/2025 - 2:00pm

Applications built on top of large-scale AI models benefit from the AI model's built-in capabilities without requiring app developers to write additional code. Essentially if the AI model can do it, an application built on top of it can do it as well. To illustrate, let's look at the impact of a model's World knowledge on an app.

For years, software applications consisted of running code and a database. As a result, their capabilities were defined by coded features and what was inside the database. When the running code is replaced by a large language model (LLM), however, the information encoded in model's weights instantly becomes part of the capabilities of the application.

With AI apps, end users are no longer constrained by the code developers had the time and foresight to write. All the World knowledge (and other capabilities) in an AI model are now part of the application's logic. Since that sounds abstract let's look at a concrete example.

I created an AI app with AgentDB by uploading a database of NBA statistics spanning 77 years and 13.6 million play-by-play records. When I add the MCP link AgentDB makes for me to Anthropic's Claude, I have an application consisting of a database optimized for AI model use, and an AI model (Claude) to use as the application's brain. Here's a video tutorial on how to do this yourself.

In the past a developer would need to write code to render the user interface for an application front-end to this database. That code would determine what kind of questions people could get answers to. Usually this meant a bunch of UI input elements to search and filter games by date, team, player, etc. The NBA's stats page (below) is a great example of this kind of interface.

But no matter how much code developers write, they can't cover all the ways people might want to interact with information about the NBA's 77 years. For instance, a question like "What were the last 5 plays in the Malice in the Palace game?" requires either running code that can translate malice in the palace to a specific date and game or an extra field in the database for game nicknames.

When a large language model is an application's compute, however, no extra code needs to be written. The association between Malice in the Palace and November 19, 2004 is present in an AI model's weights and it can translate the natural language question into a form the associated database can answer.

An AI model can use its World knowledge to translate people's questions into the kind of multi-step queries needed to answer what seem like simple questions. Consider the example below of: "Who was the tallest player drafted in Ant-Man’s NBA draft class?" We need to figure what player Ant-Man refers to, what year he was drafted, who else was drafted then, get all their heights, and then compare them. Not a simple query to write by hand but with AI acting as an application's brain... it's quick and easy.

World knowledge, of course, isn't the only capability built-in to large-language models. There's multi-language support, vision (for image parsing), tool use, and more emerging. All of these are also application capabilities when you build apps on top of AI models.

Should the CSS light-dark() Function Support More Than Light and Dark Values?

Css Tricks - Tue, 09/02/2025 - 4:44am

One of the newer CSS features that has piqued my interest: the light-dark() function. And I’ve been closely following it ever since it became Baseline back in May 2024.

The light-dark() function, briefly

If you don’t know, the light-dark() function takes two color arguments: one for light mode and one for dark mode. Hence, the name light-dark(). It toggles between the two light and dark values based on a user’s preferences. Sara Joy has a wonderful article where you can get a much more detailed explanation.

The key thing is that the function requires you to use the color-scheme property to activate those two color modes:

:root { color-scheme: light dark; } .element { color: light-dark(brown, black); }

And, depending on the user’s preference, one of those two modes is applied.

Just two modes?

That said, I’ve been wondering for a while now: Should the light-dark() function support more than light and dark color modes?

I wrote about light-dark() for the CSS-Tricks Almanac. During my research, I found myself wishing the function could do more, specifically that it lacks support for other types of color schemes that a user might prefer, such as grayscale, high contrast, and low contrast.

Does light-dark() even need a high-contrast mode?

I’d say both yes and no. Let’s go back in time to when light-dark() was initially proposed somewhere around 2022. Emilio Cobos asked for a function to support light and dark mode changes, and it was added to the specifications.

Done and handled, right? Not so fast. The ticket was indeed closed when Jacob Miller chimed in:

Just saw this from @bramus‘s post, and I suspect that things are already closed / there’s no changing things now, but I see this as an approach that doesn’t actually solve for the issues people are facing with theming, and does so in a way that will create a trap for them when pursuing proper theming support.

[…]

We shouldn’t ship single-purpose tools for the browser, but rather ones that scale and we can build upon.

Good thing he chimed in, because that prompted Bramus to reopen the ticket:

I think this was mistakingly done so. The end goal is to have something like schemed-value(), with light-dark() being an intermediary step towards the final solution.

That’s a big deal! Bramus is saying that the light-dark() function is an intermediary solution on the way to a  schemed-value() function. In other words, shipping light-dark() was never the intended end goal. It’s a step along the way to this other more robust schemed-value() feature.

Custom color schemes

Bramus has already written a bunch about the schemed-value() concept. It could look something like this:

:root { color-scheme: dark light custom; } body { color: schemed-value( light lightblue, /* Value used for light color-scheme */ dark crimson, /* Value used for dark color-scheme */ --custom green /* Value used for --custom color-scheme */ ); }

This isn’t possible with light-dark(). In fact, before the function can support more than two modes, the color-scheme property has to be extended with more than the light and dark values. Only then can light-dark() be extended because, remember, light-dark() needs the color-scheme property in order to do its thing.

Specifically, we’d need color-scheme to accept some sort of “custom” color scheme value. Tab Atkins provides a possible example in the ticket. The idea is to register a custom color scheme using a @color-scheme at-rule that defines the scheme’s properties, such as what particular color keywords are mapped to, and then use that color scheme’s ident on the color-scheme property that is declared on the root element:

@color-scheme --high-contast { base-scheme: dark; canvascolor: black; canvastext: white; accentcolor: white; /* other properties set to specific colors */ } html { color-scheme: --high-contrast; }

With that in place, the custom color scheme can be used as its own standalone value in the forthcoming schemed-value() function:

@color-scheme --high-contast { /* ... */ } html { color-scheme: --high-contrast light dark; } body { color: schemed-value(--high-contrast, black, white); }

Breaking it all down:

  • We register a custom color scheme (e.g. --high-contrast) in a @color-scheme at-rule.
  • We define the color scheme’s properties in the at-rule, such as whether its base theme is light or dark and what certain values color keywords map to.
  • We declare the custom color scheme on the color-scheme property at the root level (i.e., html { color-scheme: --high-contrast;}).
  • We apply the custom color scheme by declaring it on color-related properties by way of the schemed-value() function.

So, not only will light-dark() change, the CSS color-scheme property will most likely have its own at-rule to allow for custom color-scheme values.

We need more color theme support, but not in light-dark()

This begs my earlier question: Does the light-dark() function really need to support more than two color scheme modes? Bramus has an answer:

When schemed-value() ever becomes a thing, light-dark() would become syntactic sugar for it.

A-ha! This means light-dark() doesn’t need to support multiple modes because schemed-value() has the power to extend light-dark() by its own virtue:

light-dark(<color>, <color>); = schemed-value(light <color>, dark <color>);

Is light-dark() an intermediary step? Yes, it is. And should it be extended to support multiple modes, including custom color schemes? It certainly could, but it doesn’t have to be. Instead, we can register and define a custom color scheme in an at-rule and make sure the color-scheme property can read it. That way, we get the simplicity of a two-mode function that can be further abstracted to support additional custom modes, if needed.

In fact, it goes beyond color schemes. There is even an open ticket to extend light-dark() for images, and the discussions surrounding it seem to agree on a new function specifically designed for it.

What about custom functions?

But, wait! Doesn’t a lot of this sound a lot like what we’ve been hearing about the work happening with custom functions? Indeed, Tab came back with a possible approach using the if() function, and the Chris Lilley retagged the ticket as a result. That’s when Bramus demonstrated how we could reasonably replicate the light-dark() function with a custom CSS function:

:root { /* ensures light mode comes first */ --scheme: light; /* dark mode is set here */ @media (prefers-color-scheme: dark) { --scheme: dark; } } /* custom function returns any two values depending on whether system is in light or dark mode */ @function --light-dark(--light-color, --dark-color) { result: if(style(--scheme: dark): var(--dark-color) ; else: var(--light-color)); } p { font-size: --light-dark( 2rem, 2.5rem ); /* returns 2rem if system is in light mode and 2.5rem if system is in dark mode */ }

Nothing is set in stone! The only thing we know for sure is that we have a working light-dark() function and it’s Baseline widely available for use. Custom functions a work in progress and only available in Chromium-based browsers at the time I’m writing this.

The path forward

I’ve been exploring everything color-related for a while now, and I’d like to know your thoughts: Are you excited about the upcoming changes to light-dark()? Do you think light-dark() should support more color modes like high contrast?

Let me know your thoughts in the comment section below. Feel free to also comment on any of the W3C GitHub comment threads linked in this post to share your thoughts and concerns for the coming new features.

More on light-dark() Almanac on Jul 8, 2025 light-dark() html { color: light-dark(#000, #fff); } Sunkanmi Fafowora Article on Oct 29, 2024 Come to the light-dark() Side Sara Joy Article on Jun 5, 2025 Exploring the CSS contrast-color() Function… a Second Time Daniel Schwarz Article on Jun 26, 2025 Poking at the CSS if() Function a Little More: Conditional Color Theming Daniel Schwarz

Should the CSS light-dark() Function Support More Than Light and Dark Values? originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Chat is: the Future or a Terrible UI

LukeW - Wed, 08/27/2025 - 2:00pm

As the proliferation of AI-powered chat interfaces in software continues, people increasingly take one of two sides. Chat is the future of all UI or chat is a terrible UI. Turns out there's reason to believe both, here's a bunch of them.

Back in 2013, I proposed a variant of Jamie Zawinski's popular Law of Software Envelopment reframed as:

Every mobile app attempts to expand until it includes chat. Those applications which do not are replaced by ones which can.

Today every major mobile app has some form of chat function whether social network, e-commerce, ride-share, and so on. So chat is already pervasive and thereby familiar, which made it a great interface to usher in the age of AI. But is it AI's final form?

“Chat is the future of software.”
  • People already know how to use chat interfaces. This familiarity means people can jump right in and start using powerful AI systems.
  • An empty text box is great at capturing user intent: people can simply tell chat apps what they want to get done. “Just look at Google.”
  • Natural language allows people to communicate what they want like they would in the real World, no need to learn a UI.
  • The best interface is…  no interface, an invisible interface, etc.
  • Conversational interfaces can shift topics and goals, providing a way to compose information and actions that’s just right for specific. needs.
  • Voice input means people don’t have to type but can still simply chat with powerful systems.
  • Chat user interfaces for AI models are a fundamental shift from forcing humans to learn computers to computers understanding human language.

“Chat is a terrible user interface.”
  • Chat interfaces face the classic "invisible UI" problem: without clear affordances, people don't know what they can do, nor how to get the best results from them.
  • Walls of text are suboptimal to communicate and display complex information and relationships unlike images, tables, charts, ad more.
  • Scrolling through conversation threads to find and extract relevant information is painful, especially as chat conversations run long.
  • Context gets lost in back and forth interactions which slow everything down. Typing everything you want to do is cumbersome.
  • Language is a terrible way to describe visual, spatial, and temporal things.
  • Voice-based interfaces make it even harder to communicate information better suited to images and user interfaces.
  • We’re very early in the evolution of AI-powered software and lots of different and useful interfaces for interacting with AI will emerge.

It's also worth noting that chat isn't the only way to integrate AI in software products and increasingly agent-based applications outperform chat-only solutions. So expect things to keep changing.

A Radio Button Shopping Cart Trick

Css Tricks - Wed, 08/27/2025 - 4:18am

Editor’s note: This is a really clever idea that Preethi shared, but you will also see that it comes with accessibility drawbacks because it uses duplicated interactive elements. There are other ways to approach this sort of thing, as Preethi mentions, and we’ll look at one of them in a future article.

Two large pizzas for yourself, or twelve small ones for the kids party — everyone’s gone through the process of adding items to an online cart. Groceries. Clothing. Deli orders. It’s great when that process is simple, efficient, and maybe even a little quirky.

This post covers a design referred as infinite selection. Metaphorically infinite.

Here’s how it works:

CodePen Embed Fallback

That’s right, you click an item and it jumps right into the shopping cart, complete with a smooth transition that shows it happening. You can add as many items as you want!

And guess what: all of it is done in CSS — well, except the part that keeps count of selected items — and all it took is a combination of radio form inputs in the markup.

I’m going to walk you through the code, starting with the layout, but before that, I want to say up-front that this is just one approach. There are for sure other ways to go about this, and this specific way comes with its own considerations and limitations that we’ll get into.

The Layout

Each item (or product, whatever you want to call it) is a wrapper that contains two radio form inputs sharing the same name value — a radio group.

<div class="items flat-white"> <input type="radio" name="r3" title="Flat White"> <input type="radio" name="r3" title="Flat White"> </div>

When you check one in a duo, the other gets unchecked automatically, leading to a see-saw of check and uncheck between the two, no matter which one is clicked.

Each item (or radio group) is absolutely positioned, as are the two inputs it contains:

.items { position: absolute; input { position: absolute; inset: 0; } }

The inset property is stretching the inputs to cover the entire space, making sure they are clickable without leaving any dead area around them.

Now we arrange everything in a layout. We’ll use translate to move the items from a single point (where the centered cart is) to another point that is a litte higher and spread out. You can code this layout anyway you like, as long as the radio buttons inside can make their way to the cart when they are selected.

.items { --y: 100px; /* Vertical distance from the cart */ &:not(.cart) { transform: translate(var(--x), calc(-1 * var(--y))); } &.espresso { --x: 0px; /* Horizontal dist. from the cart */ } &.cappuccino { --x: -100%; } &.flat-white { --x: 100%; } }

So, yeah, a little bit of configuration to get things just right for your specific use case. It’s a little bit of magic numbering that perhaps another approach could abstract away.

Selecting Items

When an item (<input>) is selected (:checked), it shrinks and moves (translate) to where the cart is:

input:checked { transform: translate(calc(-1 * var(--x)), var(--y)) scale(0); }

What happens under the hood is that the second radio input in the group is checked, which immediately unchecks the first input in the group, thanks to the fact that they share the same name attribute in the HTML. This gives us a bit of boolean logic a là the Checkbox Hack that we can use to trigger the transition.

So, if that last bit of CSS moves the selected item to the shopping cart, then we need a transition to animate it. Otherwise, the item sorta zaps itself over, Star Trek style, without you telling.

input:checked{ transform: translate(calc(-1 * var(--x)), var(--y)) scale(0); transition: transform .6s linear; } Keeping Count

The whole point of this post is getting a selected item to the cart. There’s no “Cart” page to speak of, at least for the purposes of this demo. So, I thought it would be a good idea to show how many items have been added to the cart. A little label with the count should do the trick.

let n = 0; const CART_CNT = document.querySelector("output"); document.querySelectorAll("[type='radio']").forEach(radio => { radio.onclick = () => { CART_CNT.innerText = ++n; CART_CNT.setAttribute("arial-label", `${n}`) } });

Basically, we’re selecting the cart object (the <output> element) and, for each click on a radio input, we increase an integer that represents the count, which is slapped onto the shopping card icon as a label. Sorry, no removing items from the cart for this example… you’re completely locked in. &#x1f605;

Accessibility?

Honestly, I wrestled with this one and there probably isn’t a bulletproof way to get this demo read consistently by screen readers. We’re working with two interactive elements in each group, and need to juggle how they’re exposed to assistive tech when toggling their states. As it is, there are cases where one radio input is read when toggling into an item, and the other input is read when toggling back to it. In other cases, both inputs in the groups are announced, which suggests multiple options in each group when there’s only one.

I did add a hidden <span> in the markup that is revealed with keyboard interaction as a form of instruction. I’ve also inserted an aria-label on the <output> that announces the total number of cart items as they are added.

Here’s the final demo once again:

CodePen Embed Fallback Maybe Use View Transitions Instead?

I wanted to share this trick because I think it’s a clever approach that isn’t immediately obvious at first glance. But this also smells like a situation where the modern View Transition API might be relevant.

Adrian Bece writes all about it in a Smashing Magazine piece. In fact, his example is exactly the same: animating items added to a shopping cart. What’s nice about this is that it only takes two elements to build the transition: the item and the cart label. Using CSS, we can hook those elements up with a view-transition-name, define a @keyframes animation for moving the item, then trigger it on click. No duplicate elements or state juggling needed!

Alternatively, if you’re working with just a few items then perhaps a checkbox input is another possible approach that only requires a single element per item. the downside, of course, is that it limits how many items you can add to the card.

But if you need to add an infinite number of items and the View Transition API is out of scope, then perhaps this radio input approach is worth considering.

A Radio Button Shopping Cart Trick originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Platform Shifts Redefine Apps

LukeW - Tue, 08/26/2025 - 2:00pm

With each major technology platform shift, people underestimate how much "what an application is and how it's built" changes. From mainframes to PCs, to Web, to Mobile and now AI, computing platform changes redefined software and created new opportunities and constraints for application design and development.

These shifts not only impacted how applications work but also where they run, what they look like, how they're built, delivered, and experienced by people.

Mainframe era: Applications lived on massive shared computers in climate-controlled rooms, with people typing text-only commands into terminals that were basically windows into a distant brain. All the intelligence sat somewhere else, and you just got text back.

PC era: Software became physical products you'd buy in boxes, install from floppy disks or CDs, and run entirely on your own machine. Suddenly computing power lived under your desk, and applications could use rich graphical interfaces instead of just green text on black screens.

Web era: Applications moved into browsers accessed through URLs, shifting from installed software to services that updated automatically. No more version numbers or install wizards, just type an address and you're using the latest version built out of cross-platform Web standards UI components.

Mobile era: Applications shrank into task-focused apps downloaded from curated stores, designed for fingers not mice, and aware of your location and orientation. Computing became something in your pocket that could make use of the environment around you through cameras, GPS, and on-device sensors.

AI era: Instead of screens and buttons, applications are conversations where AI models understand intent, execute complex tasks, and adapt to context without explicit programming for every scenario. And we're just getting started.

While it's true that AI applications sound a lot like the mainframe applications of old, those apps required exact syntax and returned predetermined responses. AI applications understand natural language and generate solutions on the fly. They don't just process commands, they reason through problems and build UI as needed.

During each of these platform shifts, companies react the same way. They attempt to port the application models they had without thinking through and embracing what's different. Early Web site were posters and brochures. Early mobile apps were ported Websites. Just like early TV shows were just radio shows with cameras pointed at them.

But at the start of a technology platform shift, how applications will change isn't clear. It takes time for new forms to develop. As they do most companies will end up rebuilding their apps like they did for the Web, mobile, and more. Companies that embrace new capabilities and modes of building early on can gain a foothold and grow. That's why technology shifts are accompanied by a surge of new start-ups. Change is opportunity.

Getting Creative With Images in Long-Form Content

Css Tricks - Mon, 08/25/2025 - 7:16am

When you picture placing images in long-form content — like articles, case studies, or reports — the standard approach is inline rectangles, breaking up blocks of text. Functional? Sure. Inspiring? Hardly.

Why do so many long-form articles feel visually flat? Why do images so often seem bolted on, rather than part of the story? And how does that affect engagement, comprehension, or tone?

Images in long-form content can (and often should) do more than illustrate. They can shape how people navigate, engage with, and interpret what they’re reading. They help set the pace, influence how readers feel, and add character that words alone can’t always convey.

So, how do you use images to add personality, rhythm, and even surprise someone along the way? Here’s how I do it.

Patty Meltt is an up-and-coming country music sensation.

My brief: Patty Meltt is an up-and-coming country music sensation, and she needed a website to launch her new album and tour. She wanted it to be distinctive-looking and memorable, so she called Stuff & Nonsense. Patty’s not real, but the challenges of designing and developing sites like hers are.

First, a not-so-long-form recap.

You probably already know that grids make designs feel predictable, rhythmic, and structured, which helps readers feel comfortable when consuming long-form content. Grids bring balance. They help keep things aligned, organized, and easy to follow, which makes complex information feel less overwhelming.

Complex information feels less overwhelming, but the result is underwhelming.

But once I’ve established a grid, breaking it occasionally can be a powerful way to draw attention to key content, add personality, and prevent layouts from feeling formulaic or flat.

Pulling images into margins creates a casual, energetic feel.

For example, in long-form content, I might pull images into the margins or nudge them out of alignment to create a more casual, energetic feel. I could expand an image’s inline size out of its column using negative margin values:

figure { inline-size: 120%; margin-inline-start: -10%; margin-inline-end: -10%; }

Used sparingly, these breaks serve as punctuation, guiding the reader’s eye and adding moments of visual interest to the text’s flow.

Text width or full-bleed

Once we start thinking creatively about images in long-form content, one question usually comes to mind: how wide should those images be?

The image sits within the column width.

Should they sit flush with the edges of the text column?

img { inline-size: 100%; max-inline-size: 100%; } The figure element expands to fill the viewport width.

Or expand to fill the entire width of the page?

figure { inline-size: 100vw; margin-inline-start: 50%; transform: translateX(-50%); }

Both approaches are valid, but it’s important to understand how they serve different purposes.

Book and newspaper layouts traditionally keep images confined to the text column, reinforcing the flow of words. Magazines, on the other hand, regularly break the grid with full-bleed imagery for dramatic effect.

In articles, news stories, and reports, images set inside the column flow with the copy, giving a sense of order and rhythm. This works especially well for charts, diagrams, and infographics, where it’s important to keep things clear and easy to read. But in the wrong context, this approach can feel predictable and lacking in energy

Stretching images beyond the content column to fill the full width of the viewport creates instant impact. These moments act like dramatic pauses — they purposefully break the reading rhythm, reset attention, and shift focus from words to visuals. That said, these images should always serve a purpose. They lose their impact quickly if they’re overused or feel like filler.

Using a modular grid for multiple images

So far, I’ve focused on single images in the flow of text. But what if I want to present a collection? How can I arrange a sequence of images that belong together?

Instead of stacking images vertically, I can use a modular grid to create a cohesive arrangement with precise control over placement and scale. What’s a modular grid? It’s a structure built from repeated units — typically squares or rectangles — arranged horizontally and vertically to bring order to varied content. I can place individual images within single modules, or span multiple modules to create larger, more impactful zones.

figure { display: grid; grid-template-columns: repeat(4, 1fr); gap: 15px; } figure > *:nth-child(1) { grid-column: 1 / -1; }

Modular grids also help us break free from conventional, column-based layouts, adding variety and keeping things visually interesting without relying on full-bleed images every time. They give me the flexibility to mix landscape and portrait images within the same space. I can vary scale, making some images larger for emphasis and others smaller in support. It’s a layout technique that groups related visuals, reinforcing the relationship between them.

CSS Shapes and expressive possibilities

Whatever shape the subject takes, every image sits inside a box. By default, text flows above or below that box. If I float an image left or right, the adjacent text wraps around the rectangle, regardless of what’s inside. When a subject fills its box edge to edge, this wrapping feels natural.

But when the subject is cut out or has an irregular outline, that rectangular wrap can feel awkward.

CSS Shapes solves that problem by allowing text to wrap around any custom shape I define. Letting text flow around a shape isn’t just decorative — it adds energy and keeps the page feeling lively. Using shape-outside affects the reading experience. It slows people down slightly, creates visual rhythm, and adds contrast to the steady march of regular text blocks. It also brings text and image into a closer relationship, making them feel part of a shared composition rather than isolated elements.

Most shape-outside explanations start with circles or ellipses, but I think they should begin with something more expressive: wrapping text around an image’s alpha channel.

img { float: left; width: 300px; height: auto; shape-outside: url('patty.webp'); shape-image-threshold: .5; shape-margin: 1rem; }

No clipping paths. No polygons. Just letting the natural silhouette of the image shape the text. It’s a small detail that makes a design feel more considered, more crafted, and more human.

Integrating captions into a design

Captions don’t have to sit quietly beneath an image. They can play a far more expressive role in shaping how an image is perceived and understood. Most captions look like afterthoughts to me — small, grey text, tucked beneath a picture.

But when I think more deliberately about their positioning and styling, captions become an active part of the design. They can help guide attention, highlight important points, and bring a bit more personality to the page.

No rule says captions must sit below an image. Why not treat them as design elements in their own right? I might position a caption to the left or right of an image.

figure { display: grid; grid-template-columns: repeat(6, 1fr); gap: 1rem; } figure img { grid-column: 1 / 6; } figcaption { grid-column: 6; }

Or let it overlap part of the picture itself:

figure { display: grid; grid-template-columns: repeat(6, 1fr); gap: 1rem; } figure img { grid-column: 1 / 6; grid-row: 1; } figcaption { grid-column: 5 / -1; grid-row: 1; }

Captions connect images and text. Done well, they can elevate as well as explain. They don’t have to look conventional either; you can style them to look like pull quotes or side notes.

I might design a caption to echo a pull quote, or combine it with graphic elements to make it feel less like a label and more like part of the story it’s helping to tell.

The power of whitespace

Until now, I’ve concentrated on the images themselves — how they’re captioned, positioned, and sized. But there’s something else that’s just as important: the space around them.

Whitespace isn’t empty space; it’s active. It shapes how content feels, how it flows, and how it’s read. The margins, padding, and negative space around an image influence how much attention it attracts and how comfortably it sits within a page.

Tight spacing creates tension.

Tighter spacing is useful when grouping images, but it also creates tension. In contrast, generous margins give an image more breathing room.

figure { margin-block: 3rem; } Generous margins create pauses.

Like a line break in a poem or a pause in conversation, whitespace slows things down and gives people natural moments to pause while reading.

Conclusion

Images in long-form content aren’t just illustrations. They shape how people experience what they’re reading — how they move through it, how it feels, and what they remember. By thinking beyond the default rectangle, we can use images to create rhythm, personality, and even moments of surprise.

Whether it’s by breaking the grid, choosing full-bleed over inline, wrapping text, or designing playful captions, it’s about being deliberate. So next time you’re laying out a long article, don’t wonder, “Where can I put an image?” Ask, “How can this image help shape someone’s experience?

Getting Creative With Images in Long-Form Content originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

3D Layered Text: Interactivity and Dynamicism

Css Tricks - Fri, 08/22/2025 - 3:14am

In the previous two chapters, we built a layered 3D text effect, added depth and color, and then brought it to life with motion. We explored static structure, animated variations, and even some clever decoration tricks. But everything so far has been hard-coded.

This time, we’re going dynamic.

In this final chapter, we’re stepping into the world of interactivity by adding JavaScript into the mix. We’ll start by generating the layers programmatically, giving us more flexibility and cleaner code (and we’ll never have to copy-paste divs again). Then, we’ll add some interaction. Starting with a simple :hover effect, and ending with a fully responsive bulging text that follows your mouse in real time. Let’s go.

3D Layered Text Article Series
  1. The Basics
  2. Motion and Variations
  3. Interactivity and Dynamicism (you are here!)
Clean Up

Before we jump into JavaScript, let us clean things up a bit. We will pause the animations for now and go back to the static example we wrapped up with in the first chapter. No need to touch the CSS just yet. Let us start with the HTML.

We will strip it down to the bare essentials. All we really need is one element with the text. The class stays. It is still the right one for the job.

<div class="layeredText">Lorem Ipsum</div> Scripting

It is time. Let us start adding some JavaScript. Don’t worry, the impact on performance will be minimal. We’re only using JavaScript to set up the layers and define a few CSS variables. That’s it. All the actual style calculations still happen off the main thread, maintain high frames per second, and don’t stress the browser.

We will begin with a simple function called generateLayers. This is where all the magic of layer generation will happen. To work its magic, the function will receive the element we want to use as the container for the layers.

function generateLayers(element) { // magic goes here }

To trigger the function, we will first create a small variable that holds all the elements with the layeredText class. And yes, we can have more than one on the page, as we will see later. Then, we will pass each of these elements into the generateLayers function to generate the layers.

const layeredElements = document.querySelectorAll('.layeredText'); layeredElements.forEach(generateLayers); Fail Safe

Now let us dive into the generateLayers function itself and start with a small fail safe mechanism. There are situations, especially when working with frameworks or libraries that manage your DOM, where a component might get rendered more than once or a function might run multiple times. It should not happen, but we want to be ready just in case.

So, before we do anything, we will check if the element already contains a div with the .layers class. If it does, we will simply exit the function and do nothing:

function generateLayers(element) { if (element.querySelector('.layers')) return; // rest of the logic goes here }

Tip: In the real world, I would treat this as a chance to catch a rendering bug. Instead of silently returning, I would probably send a message back to the dev team with the relevant data and expect the issue to be fixed.

Counting Layers

One last thing we need to cover before we start building the layers is the number of layers. If you remember, we have a CSS variable called --layers-count, but that will not help us here. Besides, we want this to be more dynamic than a single hardcoded value.

Here is what we will do. We will define a constant in our JavaScript called DEFAULT_LAYERS_COUNT. As the name suggests, this will be our default value. But we will also allow each element to override it by using an attribute like data-layers="14".

Then we will take that number and push it back into the CSS using setProperty on the parent element, since we rely on that variable in the styles.

const DEFAULT_LAYERS_COUNT = 24; function generateLayers(element) { if (element.querySelector('.layers')) return; const layersCount = element.dataset.layers || DEFAULT_LAYERS_COUNT; element.style.setProperty('--layers-count', layersCount); } Adding Content

Now we have everything we need, and we can finally generate the layers. We will store the original text content in a variable. Then we will build the markup, setting the innerHTML of the parent element to match the structure we used in all the previous examples. That means a span with the original content, followed by a div with the .layers class.

Inside that div, we will run a loop based on the number of layers, adding a new layer in each iteration:

function generateLayers(element) { // previous code const content = element.textContent; element.innerHTML = ` <span>${content}</span> <div class="layers" aria-hidden="true"> ${Array.from({ length: layersCount}, (_, i) => `<div class="layer" style="--i: ${i + 1};">${content}</div>` ).join('')} </div> `; }

And that is it. Our 3D text is ready, and all the layers are now built entirely through JavaScript. Try playing around with it. Change the text inside the layeredText element. Add your name, your project name, your brand. Let me know how it looks.

CodePen Embed Fallback

Quick note: I also removed the --layers-count variable from the CSS, since it is now set dynamically with JavaScript. While I was at it, I moved the font settings out of the .layeredText element, since they should be applied globally or to a more appropriate wrapper. Just a bit of housekeeping to keep things clean.

Normalizing Height

Since we already added a way to set the number of layers dynamically, let us take advantage of it.

Here is an example with three different div elements, each using a different number of layers. The first one (A) has 8 layers, the second (B) has 16, and the third (C) has 24.

CodePen Embed Fallback

You can clearly see the difference in height between the letters, since the total height depends on the number of layers. When it comes to color though, we used the normalized value (remember that?), so the gradient looks consistent regardless of height or layer count.

We can just as easily normalize the total height of the layers. All we need to do is replace the --layer-offset variable with a new one called --text-height. Instead of setting the distance between each layer, we define the total height for the full stack. That lets us multiply the normalized value by --text-height, and get a consistent size no matter how many layers we have.

.layeredText { --text-height: 36px; .layer { --n: calc(var(--i) / var(--layers-count)); transform: translateZ(calc(var(--n) * var(--text-height))); color: hsl(200 30% calc(var(--n) * 100%)); } } CodePen Embed Fallback Counter Interaction

We are ready to start reacting to user input. But before we do anything, we need to think about the things we do not want to interact with, and that means the extra layers.

We already handled them for screen readers using aria-hidden, but even with regular mouse interactions, these layers can get in the way. In some cases, they might block access to clickable elements underneath.

To avoid all of that, we will add pointer-events: none; to the .layers element. This makes the layers completely ‘transparent’ to mouse clicks and hover effects.

.layers { pointer-events: none; } Hovering Links

Now we can finally start responding to user input and adding a bit of interaction. Let’s say I want to use this 3D effect on links, as a hover effect. It might be a little over the top, but we are here to have fun.

We will start with this simple markup, just a paragraph of Lorem ipsum, but with two links inside. Each link has the .layeredText class. Right now, those links will already have depth and layers applied, but that is not what we want. We want the 3D effect to appear only on hover.

To make that happen, we will define a new :hover block in .layeredText and move all the 3D related styles into it. That includes the color and shadow of the span, the color and translateZ of each .layer, and to make it look even better, we will also animate the opacity of the layers.

.layeredText { &:hover { span { color: black; text-shadow: 0 0 0.1em #003; } .layer { color: hsl(200 30% calc(var(--n) * 100%)); transform: translateZ(calc(var(--i) * var(--layer-offset) + 0.5em)); opacity: 1; } } }

Now we need to define the base appearance, the styles that apply when there is no hover. We will give the span and the layers a soft bluish color, apply a simple transition, and set the layers to be fully transparent by default.

.layeredText { display: inline-block; span, .layer { color: hsl(200 100% 75%); transition: all 0.5s; } .layer { opacity: 0; } }

Also, I added display: inline-block; to the .layeredText element. This helps prevent unwanted line breaks and allows us to apply transforms to the element, if needed. The result is a hover effect that literally makes each word pop right off the page:

CodePen Embed Fallback

Of course, if you are using this as a hover effect but you also have some elements that should always appear with full depth, you can easily define that in your CSS.

For example, let us say we have both a heading and a link with the .layeredText class, but we want the heading to always show the full 3D effect. In this case, we can update the hover block selector to target both:

.layeredText { &:is(h1, :hover) { /* full 3D styles here */ } }

This way, links will only show the effect on hover, while the heading stays bold and dimensional all the time.

CodePen Embed Fallback Mouse Position

Now we can start working with the mouse position in JavaScript. To do that, we need two things: the position of the mouse on the page, and the position of each element on the page.

We will start with the mouse position, since that part is easy. All we need to do is add a mousemove listener, and inside it, define two CSS variables on the body: --mx for the horizontal mouse position, and --my for the vertical position.

window.addEventListener('mousemove', e => { document.body.style.setProperty('--mx', e.pageX); document.body.style.setProperty('--my', e.pageY); });

Notice that I am using e.pageX and e.pageY, not e.clientX and e.clientY. That is because I want the mouse position relative to the entire page, not just the viewport. This way it works correctly even when the page is scrolled.

Position Elements

Now we need to get the position of each element, specifically the top and left values. We will define a function called setRects that loops through all layeredElements, finds their position using a getBoundingClientRect function, and sets it to a couple of CSS custom properties.

function setRects() { layeredElements.forEach(element => { const rect = element.getBoundingClientRect(); element.style.setProperty('--top', rect.top + window.scrollY); element.style.setProperty('--left', rect.left + window.scrollX); }); }

Once again, I am using window.scrollX and scrollY to get the position relative to the entire page, not just the viewport.

Keep in mind that reading layout values from the DOM can be expensive in terms of performance, so we want to do it as little as possible. We will run this function once after all the layers are in place, and again only when the page is resized, since that could change the position of the elements.

setRects(); window.addEventListener('resize', setRects); The Moving Red Dot

That is it. We are officially done writing JavaScript for this article. At this point, we have the mouse position and the position of every element stored as CSS values.

Great. So, what do we do with them?

Remember the examples from the previous chapter where we used background-image? That is the key. Let us take that same idea and use a simple radial gradient, from red to white.

.layer { background-clip: text; color: transparent; background-image: radial-gradient(circle at center, red 24px, white 0); }

But instead of placing the center of the circle in the middle of the element, we will shift it based on the mouse position. To calculate the position of the mouse relative to the element, we simply subtract the element’s position from the mouse position. Then we multiply by 1px, since the value must be in pixels, and plug it into the at part of the gradient.

.layer { background-image: radial-gradient( circle at calc((var(--mx) - var(--left)) * 1px) calc((var(--my) - var(--top)) * 1px), red 24px, white 0 ); }

The result is text with depth and a small red dot that follows the movement of your mouse.

CodePen Embed Fallback

Okay, a small red dot is not exactly mind blowing. But remember, you are not limited to that. Once you have the mouse position, you can use it to drive all sorts of dynamic effects. In just a bit, we will start building the bulging effect that kicked off this entire series, but in other cases, depending on your needs, you might want to normalize the mouse values first.

Normalizing Mouse Position

Just like we normalized the index of each layer earlier, we can normalize the mouse position by dividing it by the total width or height of the body. This gives us a value between 0 and 1.

document.body.style.setProperty('--nx', e.pageX / document.body.clientWidth); document.body.style.setProperty('--ny', e.pageY / document.body.clientHeight);

Normalizing the mouse values lets us work with relative positioning that is independent of screen size. This is perfect for things like adding a responsive tilt to the text based on the mouse position.

CodePen Embed Fallback Bulging Text

Now we are finally ready to build the last example. The idea is very similar to the red dot example, but instead of applying the background-image only to the top layer, we will apply it across all the layers. The color is stored in a custom variable and used to paint the gradient.

.layer { --color: hsl(200 30% calc(var(--n) * 100%)); color: transparent; background-clip: text; background-image: radial-gradient( circle at calc((var(--mx) - var(--left)) * 1px) calc((var(--my) - var(--top)) * 1px), var(--color) 24px, transparent 0 ); }

Now we get something similar to the red dot we saw earlier, but this time the effect spreads across all the layers.

CodePen Embed Fallback Brighter Base

We are almost there. Before we go any further with the layers, I want to make the base text look a bit weaker when the hover effect is not active. That way, we create a stronger contrast when the full effect kicks in.

So, we will make the span text transparent and increase the opacity of its shadow:

span { color: transparent; text-shadow: 0 0 0.1em #0004; }

Keep in mind, this makes the text nearly unreadable when the hover effect is not active. That is why it is important to use a proper media query to detect whether the device supports hover. Apply this styling only when it does, and adjust it for devices that do not.

@media (hover: hover) { /* when hover is supported */ } Fixing Sizes

This is it. The only thing left is to fine tune the size of the gradient for each layer. And we are done. But I do not want the bulge to have a linear shape. Using the normalized value alone will give me evenly spaced steps across all layers. That results in a shape with straight edges, like a cone.

To get a more convex appearance, we can take advantage of the new trigonometric functions available in CSS. We will take the normalized value, multiply it by 90 degrees, and pass it through a cos() function. Just like the normalized value, the cosine will return a number between 0 and 1, but with a very different distribution. The spacing between values is non-linear, which gives us that smooth convex curve.

--cos: calc(cos(var(--n) * 90deg));

Now we can use this variable inside the gradient. Instead of giving the color a fixed radius, we will multiply --cos by whatever size we want the effect to be. I also added an absolute value to the calculation, so that even when --cos is very low (close to zero), the gradient still has a minimum visible size.

And, of course, we do not want sharp, distracting edges. We want a smooth fade. So, instead of giving the transparent a hard stop point, we will give it a larger value. The difference between the var(--color) and the transparent values will control how soft the transition is.

background-image: radial-gradient( circle at calc((var(--mx) - var(--left)) * 1px) calc((var(--my) - var(--top)) * 1px), var(--color) calc(var(--cos) * 36px + 24px), transparent calc(var(--cos) * 72px) );

And just like that, we get an interactive effect that follows the mouse and gives the impression of bulging 3D text:

CodePen Embed Fallback Wrapping Up

At this point, our 3D layered text has gone from a static stack of HTML elements to a fully interactive, mouse-responsive effect. We built dynamic layers with JavaScript, normalized depth and scale, added responsive hover effects, and used live input to shape gradients and create a bulging illusion that tracks the user’s every move.

But more than anything, this chapter was about control. Controlling structure through code. Controlling behavior through input. And controlling perception through light, color, and movement. And we did it all with native web technologies.

This is just the beginning. You can keep going with noise patterns, lighting, reflections, physics, or more advanced motion behaviors. Now you have the tools to explore them, and to create bold, animated, expressive typography that jumps right off the screen.

Now go make something that moves.

3D Layered Text Article Series
  1. The Basics
  2. Motion and Variations
  3. Interactivity and Dynamicism (you are here!)

3D Layered Text: Interactivity and Dynamicism originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Five Paths to Solving Robotics

LukeW - Thu, 08/21/2025 - 2:00pm

In his AI Speaker Series presentation at Sutter Hill Ventures, Google DeepMind's Ted Xiao outlined five worldviews on how to achieve useful, ubiquitous robotics and dug into his team's work integrating frontier models like Gemini directly into robotic systems. Here' my notes from his talk:

We're at a unique moment in robotics where there's no consensus on the path forward. Unlike other AI breakthroughs where approaches quickly consolidated, robotics remains wide open with multiple reasonable paths showing early signs of success. Ted presented five worldviews, each with smart researchers and builders pursuing them with conviction:

Industry Incumbent

These researchers believe general-purpose robotics is the wrong goal. Purpose-built solutions actually work today - from industrial automation to appliances we don't even call robots anymore. When robotics succeeds, we just call them tools. The path forward: directly optimize for specific use cases using decades of control theory and hardware expertise.

Humanoid Company

These researchers see hardware as the primary bottleneck. Once platforms stabilize, researchers excel at extracting performance - drones went from fragile research prototypes to consumer products, quadrupeds became robust commercial platforms. Humanoid form factors matter because the world is built for humans, and human-like robots can better leverage internet-scale human data.

Robot Foundation Model Startup

These researchers focuses on robot data and algorithms as the key. Generality is non-negotiable - transformative technologies are general by nature. The core challenge: building an "internet of robotics data" either vertically (solve one domain completely, then expand) or horizontally (achieve robotics' GPT-2 moment first, then improve). Bitter Lesson Believer

These researchers argue frontier models are the only existence proof of technology that can model internet-scale data with human-level performance. You can't solve robotics without incorporating these "magical artifacts" into the exploration process. Frontier model trends and compute lead robotics by about two years. AGI Bro

These researchers take the most radical position: just solve AGI and ask it to solve robotics. The Platonic Representation Hypothesis suggests that as AI models improve across domains, their internal representations converge. Perfect language understanding might inherently include physical understanding.

Gemini Robotics

Ted's team at Google DeepMind pursued the Bitter Lesson approach, building robotics capabilities directly into Gemini rather than treating frontier models as black boxes.

Their Gemini Robotics system first enhanced embodied reasoning - teaching the model to understand the physical world better through 2D bounding boxes in cluttered scenes, 3D understanding with depth and orientation, pointing for granular precision, and grasp angles for manipulation. The system then learned low-level control with diverse robot actions, operating at 50Hz control frequency with quarter-second end-to-end latency. This unlocked three key advances:

  • Interactivity: The robot responds to dynamic scenes, following objects as they move and adapting to human interference
  • Dexterity: Beyond rigid objects, it can fold clothes, wrap headphone wires, and manipulate shoelaces
  • Generalization: Handles visual distribution shifts (new lighting, distractors), semantic variations (typos, different languages), and spatial changes (different sized objects requiring different strategies)

When deployed at a conference with completely novel conditions - crowds, different lighting, new table - the system maintained reasonable behavior for arbitrary user requests, showing sparks of that GPT-2 moment where it attempts something sensible regardless of input.

Dark Horses and Emerging Paradigms
  • Several emerging paradigms could completely upend current approaches.
  • Video World Models learning physics without robots through action-conditioned video generation
  • Robot-Free Data from simulation or humans with head-mounted cameras
  • Thinking Models applying frontier models' reasoning capabilities to robotics
  • Locomotion-Manipulation Unity bridging RL-based locomotion with foundation model manipulation

There's no consensus on which path will win. Each approach has reasonable arguments and early signs of success. The lack of agreement isn't a weakness - it's what makes this the most exciting time in robotics history.

3D Layered Text: Motion and Variations

Css Tricks - Wed, 08/20/2025 - 3:54am

In the previous chapter, we built a basic 3D layered text effect using nothing but HTML and CSS. It looks great and has a solid visual presence, but it’s completely static. That is about to change.

In this chapter, we will explore ways to animate the effect, add transitions, and play with different variations. We will look at how motion can enhance depth, and how subtle tweaks can create a whole new vibe.

3D Layered Text Article Series
  1. The Basics
  2. Motion and Variations (you are here!)
  3. Interactivity and Dynamicism

⚠️ Motion Warning: This article contains multiple animated examples that may include flashing or fast moving visuals. If you are sensitive to motion, please proceed with caution.

‘Counter’ Animation

Let’s start things off with a quick animation tip that pairs perfectly with layered 3D text. Sometimes, we want to rotate the element without actually changing the orientation of the text so it stays readable. The trick here is to combine multiple rotations across two axes. First, rotate the text on the z-axis. Then, add a tilt on the x-axis. Finally, rotate the text back on the z-axis.

@keyframes wobble { from { transform: rotate(0deg) rotateX(20deg) rotate(360deg); } to { transform: rotate(360deg) rotateX(20deg) rotate(0deg); } }

Since we rotate on the z-axis and then reverse that rotation, the text keeps its original orientation. But because we add a tilt on the x-axis in the middle, and the x-axis itself keeps rotating, the angle of the tilt changes as well. This creates a kind of wobble effect that shows off the text from every angle and emphasizes the sense of depth.

CodePen Embed Fallback

If we want to take this a few steps further, we can combine the wobble with a floating effect. We will animate the .layers slightly along the z-axis:

.layers { animation: hover 2s infinite ease-in-out alternate; } @keyframes hover { from { transform: translateZ(0.3em); } to { transform: translateZ(0.6em); } }

To really sell the effect, we will leave the original span in place — like a shadowed anchor — change its color to transparent, and animate the blur factor of its text-shadow:

span { color: transparent; animation: shadow 2s infinite ease-in-out alternate; } @keyframes shadow { from { text-shadow: 0 0 0.1em #000; } to { text-shadow: 0 0 0.2em #000; } }

Syncing those two animations together gives the whole thing a more realistic feel:

CodePen Embed Fallback Splitting Letters

OK, this is starting to look a lot better now that things are moving. But the whole word is still moving as one. Can we make each letter move independently? The answer, as usual, is “yes, but…”

It is absolutely possible to split each word into a separate letters and animate them individually. But it also means a lot more elements moving on the screen, and that can lead to performance issues. If you go this route, try not to animate too many letters at once, and consider reducing the number of layers.

In the next example, for instance, I reduced the layer count to sixteen. There are five letters, and to place them side by side, I gave the .scene a display: flex, then added a small delay to each letter using :nth-child:

CodePen Embed Fallback New Angles

Until now, we have only been moving the text along the z-axis, but we can definitely take it further. Each layer can be moved or rotated in any direction you like, and if we base those transformations on the --n variable, we can create all sorts of interesting effects. Here are a few I played with, just to give you some ideas.

In the first one, I am animating the translateX to create a shifting effect:

CodePen Embed Fallback

In the others, I am adding a bit of rotation. The first one is applied to the y-axis for the sloping effect:

CodePen Embed Fallback

This next example applies rotation on the x-axis for the tilting:

CodePen Embed Fallback

And, finally, we can apply it on the z-axis for a rotating example:

CodePen Embed Fallback Layer Delay

Working with separate layers does not just let us tweak the animation for each one; it also lets us adjust the animation-delay for every layer individually, which can lead to some really interesting effects. Let us take this pulsing example:

CodePen Embed Fallback

Right now, the animation is applied to the .layeredText element itself, and I am simply changing its scale:

.layeredText { animation: pulsing 2s infinite ease-out; } @keyframes pulsing { 0%, 100% { scale: 1; } 20% { scale: 1.2; } }

But we can apply the animation to each layer separately and give each one a slight delay. Note that the span is part of the stack. It is a layer, too, and sometimes you will want to include it in the animation:

.layer { --delay: calc(var(--n) * 0.3s); } :is(span, .layer) { animation: pulsing 2s var(--delay, 0s) infinite ease-out; }

Here I am using the :is selector to target both the individual layers and the span itself with the same animation. The result is a much more lively and engaging effect:

CodePen Embed Fallback Pseudo Decorations

In the previous chapter, I mentioned that I usually prefer to save pseudo elements for decorative purposes. This is definitely a technique worth using. We can give each layer one or two pseudo elements, add some content, position them however we like, and the 3D effect will already be there.

It can be anything from simple outlines to more playful shapes. Like arrows, for example:

CodePen Embed Fallback

Notice that I am using the :is selector to include the span here, too, but sometimes we will not want to target all the layers — only a specific portion of them. In that case, we can use :nth-child to select just part of the stack. For example, if I want to target only the bottom twelve layers (out of twenty four total), the decoration only covers half the height of the text. I can do something like :nth-child(-n + 12) , and the full selector would be:

:is(span, .layer:nth-child(-n + 12))::before { /* pseudo style */ }

This is especially useful when the decoration overlaps with the text, and you do not want to cover it or make it hard to read.

CodePen Embed Fallback

Of course, you can animate these pseudo elements too. So how about a 3D “Loading” text with a built-in spinner?

CodePen Embed Fallback

I made a few changes to pull this off. First, I selected twelve layers from the middle of the stack using a slightly more advanced selector: .layer:nth-child(n + 6):nth-child(-n + 18). This targets the layers from number six to eighteen.

Second, to fake the shadow, I added a blur filter to the span‘s pseudo element. This creates a nice soft effect, but it can cause performance issues in some cases, so use it with care.

:is(span, .layer:nth-child(n + 6):nth-child(-n + 18))::before { /* spinner style */ } span { /* span style */ &::before { filter: blur(0.1em); } } Face Painting

But you don’t have to use pseudo elements to add some visual interest. You can also style any text with a custom pattern using background-image. Just select the top layer with the :last-child selector, set its text color to transparent so the background shows through, and use background-clip: text.

.layer { /* layer style */ &:last-child { color: transparent; background-clip: text; background-image: ... /* use your imagination */ } }

Here is a small demo using striped lines with repeating-linear-gradient, and rings made with repeating-radial-gradient:

CodePen Embed Fallback

And, yes, you can absolutely use an image too:

CodePen Embed Fallback Animating Patterns

Let us take the previous idea a couple of steps further. Instead of applying a pattern just to the top layer, we will apply it to all the layers, creating a full 3D pattern effect. Then we will animate it.

We’ll start with the colors. First, we give all the layers a transparent text color. The color we used before will now be stored in a custom property called --color, which we will use in just a moment.

.layer { --n: calc(var(--i) / var(--layers-count)); --color: hsl(200 30% calc(var(--n) * 100%)); color: transparent; }

Now let’s define the background, and we’ll say we want a moving checkerboard pattern. We can create it using repeating-conic-gradient with two colors. The first will be our --color variable, and the second could be transparent. But in this case, I think black with very low opacity works better.

We just need to set the background-size to control the pattern scale, and of course, make sure to apply background-clip: text here too:

.layer { --n: calc(var(--i) / var(--layers-count)); --color: hsl(200 30% calc(var(--n) * 100%)); color: transparent; background-image: repeating-conic-gradient(var(--color) 0 90deg, hsl(0 0% 0% / 5%) 0 180deg); background-size: 0.2em 0.2em; background-clip: text; transform: translateZ(calc(var(--i) * var(--layer-offset))); animation: checkers 24s infinite linear; } @keyframes checkers { to { background-position: 1em 0.4em; } }

As you can see, I have already added the animation property. In this case, it is very simple to animate the pattern. Just slowly move the background-position, and that is it. Now we have text with a moving 3D pattern:

CodePen Embed Fallback Variable Fonts

So far, we have been using a single font, and as I mentioned earlier, font choice is mostly a matter of taste or brand guidelines. But since we are already working with layered text, we absolutely have to try it with variable fonts. The idea behind variable fonts is that each one includes axes you can manipulate to change its appearance. These can include width, weight, slant, or just about anything else.

Here are a few examples I really like. The first one uses the Climate Crisis font, which has a YEAR axis that ranges from 1979 to 2025. With each year, the letters melt slightly and shrink a bit. It is a powerful ecological statement, and when you stack the text in layers, you can actually see the changes and get a pretty striking 3D effect:

CodePen Embed Fallback

Another great option is Bitcount, a variable font with a classic weight axis ranging from 100 to 900. By changing the weight based on the layer index, you get a layered effect that looks like peaks rising across the text:

CodePen Embed Fallback

And here is an example that might give your browser a bit of a workout. The font Kablammo includes a MORF axis, and adjusting it completely changes the shape of each letter. So, I figured it would be fun to animate that axis (yes, font-variation-settings is animatable), and add a short delay between the layers, like we saw earlier, to give the animation a more dynamic and lively feel.

CodePen Embed Fallback Delayed Position

Before we wrap up this second chapter, I want to show you one more animation. By now you have probably noticed that there is always more than one way to do things, and sometimes it is just a matter of finding the right approach. Even the positioning of the layers, which we have been handling statically with translateZ, can be done a little differently.

If we animate the layers to move along the z-axis, from zero to the full height of the text, and add an equal delay between each one, we end up with the same visual 3D effect, only in motion.

.layer { --n: calc(var(--i) / var(--layers-count)); --delay: calc(var(--n) * -3s); animation: layer 3s var(--delay) infinite ease-in-out; } @keyframes layer { from { transform: translateZ(0); } to { transform: translateZ(calc(var(--layers-count) * var(--layer-offset))); } }

This is a more advanced technique, suited for more complex animations. It is not something you need for every use case, but for certain effects, it can look very cool.

CodePen Embed Fallback Wrapping Up

So far, we have brought the layered text effect to life with movement, variation, and creative styling. We also saw how even small changes can have a huge visual impact when applied across layers.

But everything we have done so far has been pre defined and self contained. In the next chapter, we are going to add a layer of interactivity. Literally. From simple :hover transitions to using JavaScript to track the mouse position, we will apply real-time transformations and build a fully responsive bulging effect.

3D Layered Text Article Series
  1. The Basics
  2. Motion and Variations (you are here!)
  3. Interactivity and Dynamicism

3D Layered Text: Motion and Variations originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

3D Layered Text: The Basics

Css Tricks - Mon, 08/18/2025 - 3:46am

Recently, a client asked me to create a bulging text effect. These are exactly the kinds of creative challenges I live for. I explored several directions, JavaScript solutions, SVG filters, but then I remembered the concept of 3D layered text. With a bit of cleverness and some advanced CSS, I managed to get a result I’m genuinely proud of.

Visually, it’s striking, and it’s also a perfect project to learn all sorts of valuable CSS animation techniques. From the fundamentals of layering, through element indexing, to advanced background-image tricks. And yes, we’ll use a touch of JavaScript, but don’t worry about it right now.

There is a lot to explore here, so this article is actually the first of a three part series. In this chapter, we will focus on the core technique. You will learn how to build the layered 3D text effect from scratch using HTML and CSS. We will cover structure, stacking, indexing, perspective, and how to make it all come together visually.

In chapter two, we will add movement. Animations, transitions, and clever visual variations that bring the layers to life.

In chapter three, we will introduce JavaScript to follow the mouse position and build a fully interactive version of the effect. This will be the complete bulging text example that inspired the entire series.

3D Layered Text Article Series
  1. The Basics (you are here!)
  2. Motion and Variations
  3. Interactivity and Dynamicism
The Method

Before we dive into the text, let’s talk about 3D. CSS actually allows you to create some wild three-dimensional effects. Trust me, I’ve done it. It’s pretty straightforward to move and position elements in a 3D space, and have full control over perspective. But there’s one thing CSS doesn’t give us: depth.

If I want to build a cube, I can’t just give an element a width, a height, and a depth. There is no depth, it doesn’t work that way. To build a cube or any other 3D structure in CSS, we have two main approaches: constructive and layered.

Constructive

The constructive method is very powerful, but can feel a bit fiddly, with plenty of transforms and careful attention to perspective. You take a bunch of flat elements and assemble them together, somewhere between digital Lego bricks and origami. Each side of the shape gets its own element, positioned and rotated precisely in the 3D space. Suddenly, you have a cube, a pyramid, or any other structure you want to create.

And the results can be super satisfying. There’s something unique about assembling 3D objects piece by piece, watching flat elements transform into something with real presence. The constructive method opens up a world where you can experiment, improvise, and invent new forms. You could even, for example, build a cute robot bouncing on a pogo stick.

CodePen Embed Fallback Layered

But here we’re going to focus on the layered method. This approach isn’t about building a 3D object out of sides or polygons. Instead, it’s all about stacking multiple layers, sometimes dozens of them, and using subtle shifts in position and color to create the illusion of depth. You’re tricking the eye into seeing volume and bulges where there’s really just a clever pile of flat elements.

This technique is super flexible. Think of a cube of sticky memo papers, but instead of squares, the papers are cut to shape your design. It’s perfect for text, 3D shapes, and UI elements, especially with round edges, and you can push it as far as your creativity (and patience) will take you.

CodePen Embed Fallback

Accessibility note: Keep in mind that this method can easily become a nightmare for screen reader users, especially when applied to text. Make sure to wrap all additional and decorative layers with aria-hidden="true". That way, your creative effects won’t interfere with accessibility and ensure that people using assistive technologies can still have a good experience.

Creating a 3D Layered Text

Let’s kick things off with a basic static example, using “lorem ipsum” as a placeholder (feel free to use any text you want). We’ll start with a simple container element with a class of .text. Inside, we’ll put the original text in a span (it will help later when we want to style this text separately from the layered copies), and another div with a class of “layers” where we’ll soon add the individual layers. (And don’t forget the aria-hidden.)

<div class="text"> <span>Lorem ipsum</span> <div class="layers" aria-hidden="true"></div> </div>

Now that we have our wrapper in place, we can start building out the layers themselves. In chapter three, we will see how to build the layers dynamically with JavaScript, but you can generate them easily with a simple loop in your preprocessor (if you are using one), or just add them manually in the code. Check out the pro tip below for a quick way to do that. The important thing is that we end up with something that looks like this.

<div class="layers" aria-hidden="true"> <div class="layer"></div> <div class="layer"></div> <div class="layer"></div> <!-- ...More layers --> </div>

Great, now we have our layers, but they are still empty. Before we add any content, let’s quickly cover how to assign their indexes.

Indexing the layers

Indexing simply means assigning each layer a variable (let’s call it --i) that holds its index. So, the first layer gets --i: 1;, the second gets --i: 2;, and so on. We’ll use these numbers later on as values for calculating each layer’s position and appearance.

There are a couple of ways to add these variables to your layers. You can define the value for each layer using :nth-child in CSS, (again, a simple loop in your preprocessor, if you’re using one), or you can do it inline, giving each layer element a style attribute with the right --i value.

.layer { &:nth-child(1): { --i: 1; } &:nth-child(2): { --i: 2; } &:nth-child(3): { --i: 3; } /* ... More layers */ }

…or:

<div class="layers" aria-hidden="true"> <div class="layer" style="--i: 1;"></div> <div class="layer" style="--i: 2;"></div> <div class="layer" style="--i: 3;"></div> <!-- ...More layers --> </div>

In this example, we will go with the inline approach. It gives us full control, keeps things easy to understand, and avoids dependency between the markup and the stylesheet. It also makes the examples copy friendly, which is great if you want to try things out quickly or tweak the markup directly.

Pro tip: If you’re working in an IDE with Emmet support, you can generate all your layers at once by typing .layer*24[style="--i: $;"] and pressing Tab. The .layer is your class, *24 is the number of elements, attributes go in square brackets [ ], and $ is the incrementing number. But, If you’re reading this in the not-so-distant future, you might be able to use sibling-index() and not even need these tricks. In that case, you won’t need to add variables to your elements at all, just swap out var(--i) for sibling-index() in the next code examples.

Adding Content

Now let us talk about adding content to the layers. Each layer needs to contain the original text. There are a few ways to do this. In the next chapter, we will see how to handle this with JavaScript, but if you are looking for a CSS-only dynamic solution, you can add the text as the content of one of the layer’s pseudo elements. This way, you only need to define the text in a single variable, which makes it a great fit for titles, short labels, or anything that might change dynamically.

.layer { --text: "Lorem ipsum"; &::before { content: var(--text); } }

The downside, of course, is that we are creating extra elements, and I personally prefer to save pseudo elements for decorative purposes, like the border effect we saw earlier. We will look at more examples of that in the next chapter.

A better, more straightforward approach is to simply place the text inside each layer. The downside to this method is that if you want to change the text, you will have to update it in every single layer. But since in this case the example is static and I do not plan on changing the text, we will simply use Emmet, putting the text inside curly braces {}.

So, we will type .layers*24[style="--i: $;"]{Lorem ipsum} and press Tab to generate the layers.

<div class="text"> Lorem ipsum <div class="layers" aria-hidden="true"> <div class="layer" style="--i: 1;">Lorem ipsum</div> <div class="layer" style="--i: 2;">Lorem ipsum</div> <div class="layer" style="--i: 3;">Lorem ipsum</div> <!-- ...More layers --> </div> </div> Let’s Position

Now we can start working on the styling and positioning. The first thing we need to do is make sure all the layers are stacked in the same place. There are a few ways to do this as well , but I think the easiest approach is to use position: absolute with inset: 0 on the .layers and on each .layer, making sure every layer matches the container’s size exactly. Of course, we’ll set the container to position: relative so that all the layers are positioned relative to it.

.text { position: relative; .layers, .layer { position: absolute; inset: 0; } } Adding Depth

Now comes the part that trips some people up, adding perspective. To give the text some depth, we’re going to move each layer along the z-axis, and to actually see this effect, we need to add a bit of perspective.

As with everything so far, there are a few ways to do this. You could give perspective to each layer individually using the perspective() function, but my recommendation is always to apply perspective at the parent level. Just wrap the element (or elements) you want to bring into the 3D world inside a wrapper div (here I’m using .scene) and apply the perspective to that wrapper.

After setting the perspective on the parent, you’ll also need to use transform-style: preserve-3d; on each child of the .scene. Without this, browsers flatten all transformed children into a single plane, causing any z-axis movement to be ignored and everything to look flat. Setting preserve-3d; ensures that each layer’s 3D position is maintained inside the parent’s 3D context, which is crucial for the depth effect to come through.

.scene { perspective: 400px; * { transform-style: preserve-3d; } }

In this example, I’m using a fairly low value for the perspective, but you should definitely play around with it to suit your own design. This value represents the distance between the viewer and the object, which directly affects how much depth we see in the transformed layers. A smaller value creates a stronger, more exaggerated 3D effect, while a larger value makes the scene appear flatter. This property is what lets us actually see the z-axis movement in action.

Layer Separation

Now we can move the layers along the z-axis, and this is where we start using the index values we defined earlier. Let’s start by defining two custom properties that we’ll use in a moment: --layers-count, which holds the number of layers, and --layer-offset, which is the spacing between each layer.

.text { --layers-count: 24; --layer-offset: 1px; }

Now let’s set the translateZ value for each layer. We already have the layer’s index and the spacing between layers, so all we need to do is multiply them together inside the transform property.

.layer { transform: translateZ(calc(var(--i) * var(--layer-offset))); }

This feels like a good moment to stop and look at what we have so far. We created the layers, stacked them on top of each other, added some content, and moved them along the z-axis to give them depth. And this is where we’re at:

CodePen Embed Fallback

If you really try, and focus hard enough, you might see something that kind of looks like 3D. But let’s be honest, it does not look good. To create a real sense of depth, we need to bring in some color, add a bit of shadow, and maybe rotate things a bit for a more dynamic perspective.

Forging Shadows

Sometimes we might want (or need) to use the value of --i as is, like in the last snippet, but for some calculations, it’s often better to normalize the value. This means dividing the index by the total number of layers, so we end up with a value that ranges from 0 to 1. By normalizing, we keep our calculations flexible and proportional, so the effect remains balanced even if the number of layers changes.

.layer { --n: calc(var(--i) / var(--layers-count)); }

Now we can adjust the color for each layer, or more precisely, the brightness of the color. We’ll use the normalized value on the ‘light’ of a simple HSL function, and add a touch of saturation with a bluish hue.

.layer { color: hsl(200 30% calc(var(--n) * 100%)); }

Gradually changing the brightness between layers helps create a stronger sense of depth in the text. And without it, you risk losing some of the finer details

CodePen Embed Fallback

Second, remember that we wrapped the original text in a span so we could style it? Now is the time to use it. Since this text sits on the bottom layer, we want to give it a darker color than the rest. Black works well here, and in most cases, although in the next chapter we will look at examples where it actually needs to be transparent.

span { color: black; text-shadow: 0 0 0.1em #003; } Final Touches

Before we wrap this up, let us change the font. This is of course a matter of personal taste or brand guidelines. In my case, I am going with a bold, chunky font that works well for most of the examples. You should feel free to use whatever font fits your style.

Let us also add a slight rotation to the text, maybe on the x-axis, so the lettering appears at a better angle:

.text { font-family: Montserrat, sans-serif; font-weight: 900; transform: rotateX(30deg); }

And there you have it, combining all the elements we’ve covered so far: the layers, indexes, content, perspective, positioning, and lighting. The result is a beautiful, three-dimensional text effect. It may be static for now, but we’ll take care of that soon.

CodePen Embed Fallback Wrapping Up

At this point, we have a solid 3D text effect built entirely with HTML and CSS. We covered everything from structure and indexing to layering, depth, and color. It may still be static, but the foundation is strong and ready for more.

In the next chapters, we are going to turn things up. We will add motion, introduce transitions, and explore creative ways to push this effect further. This is where it really starts to come alive.

3D Layered Text Article Series
  1. The Basics (you are here!)
  2. Motion and Variations
  3. Interactivity and Dynamicism

3D Layered Text: The Basics originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Rethinking Applications for AI

LukeW - Sun, 08/17/2025 - 2:00pm

With every new technology platform, the concept of an application shifts. Consider the difference between compiled apps during the PC era, online applications during the Web, and app stores during mobile. Now with AI it's happening again.

Before getting into the impact AI is having on applications, it's worth noting we still have downloadable desktop applications, Web applications, mobile app stores and everything in between. Technology platform shifts don't wipe out the past and they also don't happen overnight. So AI-driven changes, while happening fast, are going to be happening for a long time.

The basic components of an application have also stayed consistent for a long time. An application at its highest level is just running code and a database. The database stores the information an application manipulates and the running code allows you to manipulate it through input and output controls (user interface, auth, etc.).

As AI coding agents have gotten more capable, they've increasingly been able to handle more of the running code aspect of an application. Not only can they generate code, they can review it, fix it, and maintain it. So it's not hard to see how AI agents can be a self-sustaining loop.

As AI coding agents take on more and more of the running code aspect of an application, they increasingly need to create, update, and work with databases. Today's databases, however, were made for people to use, not agents. So we built a database system for AI applications called AgentDB designed for agents, not people.

AgentDB allows agents to manifest new databases by just referencing a unique ID. Instead of filling out a series of forms - like people do when creating a database. It also provides agents with templates that let them start using databases immediately and consistently across use cases. These templates are dynamic so as agents learn new or better ways to use a database, that information is passed on to all subsequent agent use.

With these two changes, the concept of an application is already shifting. But what if the idea of needing "running code" is also changing? By fronting an AgentDB database and template system with a remote Model Context Protocol (MCP) server: all you need is a URL plus an AI model to have an app.

All you need is a URL plus an AI model to have an app.

In this video, I demonstrate uploading a CSV file of a credit card statement to AgentDB. The system creates a database and template, encapsulates both with a remote MCP server URL that you can add to any AI application that supports remote MCP like Claude, Cursor, Augment Code, etc. The end result is an instant chat app.

Through natural language instructions, you can read and write data immediately and consistently and ask for any variant of user interface you want. Most credit card websites are painfully limiting but now I can create the specific visualizations, categories, queries, and features I want. No waiting around for the credit card site to implement new code.

You also don't need a CSV file to make an app. Just tell an AI model connected to AgentDB what you want. It can use AgentDB to create a database, populate it, and then ensure anything you add to it includes the right information. Tracking the date, location, and cost of concert tickets? AgentDB will enforce all that info is there and if you add a new bit of data to track, it can update all your records (see video below).

You can try making your own chat app from a database or CSV file at the demo page on AgentDB to get a feel for it. There's definitely some rough edges especially when trying to add a remote MCP server to some AI applications (in fact, this whole step should go away) but it's still pretty compelling.

As I mentioned at the start, we don't fully know how the AI platform shift will transform applications yet. Clearly, though, there's big changes coming.

Covering hidden=until-found

Css Tricks - Fri, 08/15/2025 - 2:55am

Filing this in the “Missed First Time Around” category. It popped up in the Firefox 139 release notes and I was, like, ooo neat. Then I saw it’s been in Chrome since at least 2022. And as I wrote this, it landed in Safari Technology Preview 125. So there you have it.

Turns out there are a few good posts and tutorials about hidden=until-found floating out there, so I thought I’d jot down a few key takeaways for later reference.

It makes hidden content “findable”

Short story: Slapping hidden=until-found on an element in HTML enables any hidden content within the element to be findable in the browser with in-page search.

<div hidden="until-found"> <!-- hidden content --> </div>

You’ll see, or more accurately not see, that the content is hidden with that in place:

CodePen Embed Fallback It’s content-visibility: hidden under the hood

The browser takes that as a hint to hide the content and does so by implicitly setting content-visibility: hidden on the element in the user agent styles.

If we do a Ctrl+F on the keyboard to activate in-page search and enter a query, then a match reveals the content, highlighting said matched query.

Why we need this

That’s what I was asking myself when I started digging into this a little deeper. The most prominent example of it being used is from the Chrome for Developers docs as a faux-accordion. You know, a series of panels that open and close on click.

CodePen Embed Fallback

But isn’t that a solved deal now that we have the <details> element at the ready? May as well use a semantic disclosure widget to, you know, disclose content. Indeed, browsers also set content-visibility: hidden on the ::details-content portion of the element that holds the content.

I’m pretty sure <details> was not as widely supported in 2022 as it is today. It’s actually part of Interop 2025 and notice that one of the functionalities mentioned is the capability for in-page search. Chrome already supports it. Firefox recently shipped it (ostensibly as part of the hidden=until-found release). And Safari will presumably get there with Interop 2025. The example from the Chrome for Developers post demonstrates an approach for working around a not-fully-supported <details> element and now we have it.

So, why hidden=until-closed?

I don’t know. I’m sure there’s a good use case for hiding content accessibly in some fashion while making it searchable. I just can’t think of it off the top of my head. I mean, we have popover as well, but that takes a different approach with display: none which completely removes the content from in-page search.

Browser support and polyfill

We’ve already established that Chrome and Firefox are on board. Safari is the bigger holdout, but knowing that making the hidden content in <details> findable is part of Interop 2025 (and Firefox’s corresponding support for it as part of that effort) makes me think it’s around the corner. (Turns out that hunch was correct because it landed in Safari Technology Preview 125 after writing this.)

In the meantime, though, is it worth using hidden=until-found? Because if we’re aiming for a consistent cross-browser experience, we’d need to do some sort of swap between content-visibility: hidden to hide the content and content-visible: auto to reveal it.

Nathan Knowler expertly explains the conundrum this creates. We can’t set content-visibility: hidden on something without also removing it from in-page search. The hidden=until-found attribute works exactly like content-visibility: hidden but maintains that in-page search still works. In other words, we can’t polyfill the feature with content-visibility.

Thanks, Nathan, for going down the massive rabbit hole and finding a solution that leverages the Shadow DOM to look for the HTML attribute, check support, and revert its properties when needed to accessibly hide the content visually without fully nuking it from being found.

Styling

Seems like there isn’t much to say about styling something that ain’t visible, but notice that the in-page search feature highlights content that matches the search query.

Looks like we may get a new ::search-text pseudo that allows us to select the matched query and style the highlight color in the CSS Pseudo-Elements Module Level 4 specification, which is currently in Editor’s Draft status at the time I’m writing this.

What about multiple matches? The current selection gets a different highlight from subsequent matches.

We’ll presumably, according to the spec, be able to combine ::search-text with the :current pseudo-class to target the current match: ::search-text:current.

If you’re thinking we might get to mix-and-match ::search-text with the corresponding :past and :future pseudo-classes, I’m afraid the spec says nay. But it does not shut the door on it completely:

The :past and :future pseudo-classes are reserved for analogous use in the future. Any unsupported combination of these pseudo-classes with ::search-text must be treated as invalid.

Anything else?

Not really, but I do like the note at the end of Christian Shaefer’s “Rethinking Find-in-Page Accessibility” post that says consideration needs to go into what happens after a search query matches content on the page. Currently, the content remains visible even after in-page search is closed or canceled. Perhaps we’ll need some other HTML hint for that.

Links

A dump of things I found and used while researching this:

Covering hidden=until-found originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

A Few Things About the Anchor Element’s href You Might Not Have Known

Css Tricks - Fri, 08/15/2025 - 2:26am

I love “re-learning” things I thought I knew. HTML is full of those opportunities (case in point, like today) since it’s where you typically start learning about web development. And in those early days, you don’t know what you don’t know.

So, thanks Jim Nielsen for giving me a reason to give URL patterns another look. It’s easy to take URL superpowers for granted, even if you already have these patterns under your belt.

The patterns:

<a href="#"> <!-- Scrolls to the top of a document --> <a href=""> <!-- Reloads the current page, preserving the search string but removing the hash string (if present). --> <a href="."> <!-- Reloads the current page, removing both the search and hash strings --> <a href="?"> <!-- Reloads the current page, removing both the search and hash strings (keeps `?`) --> <a href="data:"> <!-- Link to data URLs, like text fragments --> <a href="video.mp4#t=10,20"> <!-- Links to specific parts of a media file -->

But do yourself a favor and read Jim’s full post. He gets way more into the weeds, referencing the specification and stress testing different configurations. I mean, this is gold:

But I’m writing because #top will also scroll to the top if there isn’t another element with id="top" in the document. I didn’t know that.

Me neither.

A Few Things About the Anchor Element’s href You Might Not Have Known originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Syndicate content
©2003 - Present Akamai Design & Development.