Front End Web Development

Steven Heller’s Font of the Month: OTC Textura

Typography - Mon, 10/06/2025 - 6:35pm

Read the book, Typographic Firsts

Blackletter typefaces elicit many contradictory emotions depending, of course, on the context in which they are used and the manner in which they are composed. Sometimes they bark commands – STOP or BEWARE. Other times they are comforting in an ecclesiastical way – Christmas and Easter greetings. During World War II Blackletter was menacing for […]

The post Steven Heller’s Font of the Month: OTC Textura appeared first on I Love Typography.

Getting Creative With shape-outside

Css Tricks - Mon, 10/06/2025 - 5:45am

Last time, I asked, “Why do so many long-form articles feel visually flat?” I explained that:

“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.”

Then, I touched on the expressive possibilities of CSS Shapes and how, by using shape-outside, you can wrap text around an image’s alpha channel to add energy to a design and keep it feeling lively.

There are so many creative opportunities for using shape-outside that I’m surprised I see it used so rarely. So, how can you use it to add personality to a design? 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.

Most shape-outside guides start with circles and polygons. That’s useful, but it answers only the how. Designers need the why — otherwise it’s just another CSS property.

Whatever shape its 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 text wraps around the rectangle, regardless of what’s inside. That’s the limitation shape-outside overcomes.

shape-outside lets you break free from those boxes by enabling layouts that can respond to the contours of an image. That shift from images in boxes to letting the image content define the composition is what makes using shape-outside so interesting.

Solid blocks of text around straight-edged images can feel static. But text that bends around a guitar or curves around a portrait creates movement, which can make a story more compelling and engaging.

CSS shape-outside enables text to flow around any custom shape, including an image’s alpha channel (i.e., the transparent areas):

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

First, a quick recap.

For text to flow around elements, they need to float either left or right and have their width defined. The shape-outside URL selects an image with an alpha channel, such as a PNG or WebP. The shape-image-threshold property sets the alpha channel threshold for creating a shape. Finally, there’s the shape-margin property which — believe it or not — creates a margin around the shape.

Interactive examples from this article are available in my lab.

Multiple image shapes

When I’m adding images to a long-form article, I ask myself, “How can they help shape someone’s experience?” Flowing text around images can slow people down a little, making their experience more immersive. Visually, it brings text and image into a closer relationship, making them feel part of a shared composition rather than isolated elements.

Columns without shape-outside applied to them

Patty’s life story — from singing in honky-tonks to headlining stadiums — contains two sections: one about her, the other about her music. I added a tall vertical image of Patty to her biography and two smaller album covers to the music column:

<section id="patty"> <div> <img src="patty.webp" alt=""> [...] </div> <div> <img src="album-1.webp" alt=""> [...] <img src="album-2.webp" alt=""> [...] </div> </section>

A simple grid then creates the two columns:

#patty { display: grid; grid-template-columns: 2fr 1fr; gap: 5rem; }

I like to make my designs as flexible as I can, so instead of specifying image widths and margins in static pixels, I opted for percentages on those column widths so their actual size is relative to whatever the size of the container happens to be:

#patty > *:nth-child(1) img { float: left; width: 50%; shape-outside: url("patty.webp"); shape-margin: 2%; } #patty > *:nth-child(2) img:nth-of-type(1) { float: left; width: 45%; shape-outside: url("album-1.webp"); shape-margin: 2%; } #patty > *:nth-child(2) img:nth-of-type(2) { float: right; width: 45%; shape-outside: url("album-2.webp"); shape-margin: 2%; } Columns with shape-outside applied to them. See this example in my lab.

Text now flows around Patty’s tall image without clipping paths or polygons — just the natural silhouette of her image shaping the text.

Building rotations into images.

When an image is rotated using a CSS transform, ideally, browsers would reflow text around its rotated alpha channel. Sadly, they don’t, so it’s often necessary to build that rotation into the image.

shape-outside with a faux-centred image

For text to flow around elements, they need to be floated either to the left or right. Placing an image in the centre of the text would make Patty’s biography design more striking. But there’s no center value for floats, so how might this be possible?

Patty’s image set between two text columns. See this example in my lab.

Patty’s bio content is split across two symmetrical columns:

#dolly { display: grid; grid-template-columns: 1fr 1fr; }

To create the illusion of text flowing around both sides of her image, I first split it into two parts: one for the left and the other for the right, both of which are half, or 50%, of the original width.

Splitting the image into two pieces.

Then I placed one image in the left column, the other in the right:

<section id="dolly"> <div> <img src="patty-left.webp" alt=""> [...] </div> <div> <img src="patty-right.webp" alt=""> [...] </div> </section>

To give the illusion that text flows around both sides of a single image, I floated the left column’s half to the right:

#dolly > *:nth-child(1) img { float: right; width: 40%; shape-outside: url("patty-left.webp"); shape-margin: 2%; }

…and the right column’s half to the left, so that both halves of Patty’s image combine right in the middle:

#dolly > *:nth-child(2) img { float: left; width: 40%; shape-outside: url("patty-right.webp"); shape-margin: 2%; } Faux-centred image. See this example in my lab. Faux background image

So far, my designs for Patty’s biography have included a cut-out portrait with a clearly defined alpha channel. But, I often need to make a design that feels looser and more natural.

Faux background image. See this example in my lab.

Ordinarily, I would place a picture as a background-image, but for this design, I wanted the content to flow loosely around Patty and her guitar.

Large featured image

So, I inserted Patty’s picture as an inline image, floated it, and set its width to 100%;

<section id="kenny"> <img src="patty.webp" alt=""> [...] </section> #kenny > img { float: left; width: 100%; max-width: 100%; }

There are two methods I might use to flow text around Patty and her guitar. First, I might edit the image, removing non-essential parts to create a soft-edged alpha channel. Then, I could use the shape-image-threshold property to control which parts of the alpha channel form the contours for text wrapping:

#kenny > img { shape-outside: url("patty.webp"); shape-image-threshold: 2; } Edited image with a soft-edged alpha channel

However, this method is destructive, since much of the texture behind Patty is removed. Instead, I created a polygon clip-path and applied that as the shape-outside, around which text flows while preserving all the detail of my original image:

#kenny > img { float: left; width: 100%; max-width: 100%; shape-outside: polygon(…); shape-margin: 20px; } Original image with a non-destructive clip-path.

I have little time for writing polygon path points by hand, so I rely on Bennett Feely’s CSS clip-path maker. I add my image URL, draw a custom polygon shape, then copy the clip-path values to my shape-outside property.

Bennett Feely’s CSS clip path maker. Text between shapes

Patty Meltt likes to push the boundaries of country music, and I wanted to do the same with my design of her biography. I planned to flow text between two photomontages, where elements overlap and parts of the images spill out of their containers to create depth.

Text between shapes. See this example in my lab.

So, I made two montages with irregularly shaped alpha channels.

Irregularly shaped alpha channels

I placed both images above the content:

<section id="johnny"> <img src="patty-1.webp" alt=""> <img src="patty-2.webp" alt=""> […] </section>

…and used those same image URLs as values for shape-outside:

#johnny img:nth-of-type(1) { float: left; width: 45%; shape-outside: url("patty-1.webp"); shape-margin: 2%; } #johnny img:nth-of-type(2) { float: right; width: 35%; shape-outside: url("img/patty-2.webp"); shape-margin: 2%; }

Content now flows like a river in a country song, between the two image montages, filling the design with energy and movement.

Conclusion

Too often, images in long-form content end up boxed in and isolated, as if they were dropped into the page as an afterthought. CSS Shapes — and especially shape-outside — give us a chance to treat images and text as part of the same composition.

This matters because design isn’t just about making things usable; it’s about shaping how people feel. Wrapping text around the curve of a guitar or the edge of a portrait slows readers down, invites them to linger, and makes their experience more immersive. It brings rhythm and personality to layouts that might otherwise feel flat.

So, next time you reach for a rectangle, pause and think about how shape-outside can help turn an ordinary page into something memorable.

Getting Creative With shape-outside originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Same Idea, Different Paint Brush

Css Tricks - Wed, 10/01/2025 - 3:02am

There’s the idiom that says everything looks like a nail when all you have is a hammer. I also like the one about worms in horseradish seeing the world as horseradish.

That’s what it felt like for me as I worked on music for an album of covers I released yesterday.

I was raised by my mother, a former high school art teacher (and a gifted artist in her own right), who exposed me to a lot of different tools and materials for painting and drawing. I’m convinced that’s what pointed me in the direction of web development, even though we’re talking years before the internet of AOL and 56K dial-up modems. And just as there’s art and craft to producing a creative 2D visual on paper with wet paint on a brush, there’s a level of art and craft to designing user interfaces that are written in code.

You might even say there’s a poetry to code, just as there’s code to writing poetry.

I’ve been painting with code for 20 years. HTML, CSS, JavaScript, and friends are my medium, and I’ve created a bunch of works since then. I know my mom made a bunch of artistic works in her 25+ years teaching and studying art. In a sense, we’re both artists using a different brush to produce works in different mediums.

Naturally, everything looks like code when I’m staring at a blank canvas. That’s whether the canvas is paper, a screen, some Figma artboard, or what have you. Code is my horseradish and I’ve been marinating in this horseradish ocean for quite a while.

This is what’s challenging to me about performing and producing an album of music. The work is done in a different medium. The brush is no longer code (though it can be) but sounds, be them vibrations that come from a physical instrument or digital waves that come from a programmed beat or sample.

There are parallels between painting with code and painting with sound, and it is mostly a matter of approach. The concepts, tasks, and challenges are the same, but the brush and canvas are totally different.

What’s in your stack?

Sound is no different than the web when it comes to choosing the right tools to do the work. Just as you need a stack of technical tools to produce a website or app, you will need technical tools to capture and produce sounds, and the decision affects how that work happens.

For example, my development environment might include an editor app for writing code, a virtual server to see my work locally, GitHub for version control and collaboration, some build process that compiles and deploys my code, and a host that serves the final product to everyone on the web to see.

Making music? I have recording software, microphones, gobs of guitars, and an audio interface that connects them together so that the physical sounds I make are captured and converted to digital sound waves. And, of course, I need a distributor to serve the music to be heard by others just as a host would serve code to be rendered as webpages.

Can your website’s technical stack be as simple as writing HTML in a plain text editor and manually uploading the file to a hosting service via FTP? Of course! Your album’s technical stack can just as easily be a boombox with a built in mic and recording. Be as indie or punk as you want!

Either way, you’ve gotta establish a working environment to do the work, and that environment requires you to make decisions that affect the way you work, be it code, music, or painting for that matter. Personalize your process and make it joyful.

It’s the “Recording Experience” (EX) to what we think of as Developer Experience (DX).

What’re you painting on?

If you’re painting, it could be paper. But what kind of paper? Is college-rule cool or do you need something more substantial with heavier card stock? You’re going to want something that supports the type of paint you’re using, whether it’s oil, water, acrylic… or lead? That wouldn’t be good.

On the web, you’re most often painting on a screen that measures its space in pixel units. Screens are different than paper because they’re not limited by physical constraints. Sure, the hardware may pose a constraint as far as how large a certain screen can be. But the scene itself is limitless where we can scroll to any portion of it that is not in the current frame. But please, avoid AJAX-based infinite scrolling patterns in your work for everyone’s sake.

I’m also painting music on a screen that’s as infinite as the canvas of a webpage. My recording software simply shows me a timeline and I paint sound on top of time, often layering multiple sounds at the same point in time — sound pictures, if you will.

That’s simply one way to look at it. In some apps, it’s possible to view the canvas as movements that hold buckets of sound samples.

Same thing with code. Authoring code is as likely to happen in a code editor you type into as it is to happen with a point-and-click setup in a visual interface that doesn’t require touching any code at all (Dreamweaver, anyone?). Heck, the kids are even “vibe” coding now without any awareness of how the code actually comes together. Or maybe you’re super low-fi and like to sketch your code before sitting behind a keyboard.

How’re people using it?

Web developers be like all obsessed with how their work looks on whatever device someone is using. I know you know what I’m talking about because you not only resize browsers to check responsiveness but probably also have tried opening your site (and others!) on a slew of different devices.

⚠️ Auto-playing media

It’s no different with sound. I’ve listened to each song I’ve recorded countless times because the way they sound varies from speaker to speaker. There’s one song in particular that I nearly scrapped because I struggled to get it sounding good on my AirPods Max headphones that are bass-ier than your typical speaker. I couldn’t handle the striking difference between that and a different output source that might be more widely used, like car speakers.

Will anyone actually listen to that song on a pair of AirPods Max headphones? Probably not. Then again, I don’t know if anyone is viewing my sites on some screen built into their fridge or washing machine, but you don’t see me rushing out to test that. I certainly do try to look at the sites I make on as many devices as possible to make sure nothing is completely busted.

You can’t control what device someone uses to look at a website. You can’t control what speakers someone uses to listen to music. There’s a level of user experience and quality assurance that both fields share. There’s a whole other layer about accessibility and inclusive design that fits here as well.

There is one big difference: The cringe of listening to your own voice. I never feel personally attached to the websites I make, but listening to my sounds takes a certain level of vulnerability and humility that I have to cope with.

The creative process

I mentioned it earlier, but I think the way music is created shares a lot of overlap with how websites are generally built.

For example, a song rarely (if ever) comes fully formed. Most accounts I read of musicians discussing their creative process talk about the “magic” of a melody in which it pretty much falls in the writer’s lap. It often starts as the germ of an idea and it might take minutes, days, weeks, months, or even years to develop it into a comprehensive piece of work. I keep my phone’s Voice Memos app at the ready so that I’m able to quickly “sketch” ideas that strike me in the moment. It might simply be something I hum into the phone. It could be strumming a few chords on the guitar that sound really nice together. Whatever it is, I like to think of those recordings as little low-fidelity sketches, not totally unlike sketching website layouts and content blocks with paper and pencil.

I’m partial to sketching websites on paper and pencil before jumping straight into code. It’s go time!

And, of course, there’s what you do when it’s time to release your work. I’m waist-deep in this part of the music and I can most definitely say that shipping an album has as many moving parts, if not more, than deploying a website. But they both require a lot of steps and dependencies that complicate the process. It’s no exaggeration that I’m more confused and lost about music publishing and distribution than I ever felt learning about publishing and deploying websites.

It’s perfectly understandable that someone might get lost when hosting a website. There’s so many ways to go about it, and the “right” way is shrouded in the cloak of “it depends” based on what you’re trying to accomplish.

Well, same goes for music, apparently. I’ve signed up for a professional rights organization that establishes me as the owner of the recordings, very similar to how I need to register myself as the owner of a particular web domain. On top of that, I’ve enlisted the help of a distributor to make the songs available for anyone to hear and it is exactly the same concept as needing a host to distribute your website over the wire.

I just wish I could programmatically push changes to my music catalog. Uploading and configuring the content for an album release reminds me so much of manually uploading hosted files with FTP. Nothing wrong with that, of course, but it’s certainly an opportunity to improve the developer recording experience.

So, what?

I guess what triggered this post is the realization that I’ve been in a self-made rut. Not a bad one, mind you, but more like being run by an automated script programmed to run efficiently in one direction. Working on a music project forced me into a new context where my development environment and paint brush of code are way less effective than what I need to get the job done.

It’s sort of like breaking out of the grid. My layout has been pretty fixed for some time and I’m drawing new grid tracks that open my imagination up to a whole new way of work that’s been right in front of me the entire time, but drowned in my horseradish ocean. There’s so much we can learn from other disciplines, be it music, painting, engineering, architecture, working on cars… turns out front-end development is like a lot of other things.

So, what’s your horseradish and what helps you look past it?

Same Idea, Different Paint Brush originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Touring New CSS Features in Safari 26

Css Tricks - Mon, 09/29/2025 - 4:31am

A couple of days ago, the Apple team released Safari 26.0! Is it a big deal? I mean, browsers release new versions all the time, where they sprinkle in a couple or few new features. They are, of course, all useful, but there aren’t usually a lot of “big leaps” between versions. Safari 26 is different, though. It introduces a lot of new stuff. To be precise, it adds: 75 new features, 3 deprecations, and 171 other improvements.

I’d officially call that a big deal.

The WebKit blog post does an amazing job breaking down each of the new (not only CSS) features. But again, there are so many that the new stuff coming to CSS deserves its own spotlight. So, today I want to check (and also try) what I think are the most interesting features coming to Safari.

If you are like me and don‘t have macOS to test Safari, you can use Playwright instead.

What’s new (to Safari)?

Safari 26 introduces several features you may already know from prior Chrome releases. And… I can’t blame Safari for seemingly lagging behind because Chrome is shipping new CSS at a scarily fast pace. I appreciate that browsers stagger releases so they can refine things against each other. Remember when Chrome initially shipped position-area as inset-area? We got better naming between the two implementations.

I think what you’ll find (as I did) that many of these overlapping features are part of the bigger effort towards Interop 2025, something WebKit is committed to. So, let’s look specifically at what’s new in Safari 26… at least that’s new to Safari.

Anchor positioning

Anchor positioning is one of my favorite features (I wrote the guide on it!), so I am so glad it’s arrived in Safari. We are now one step closer to widely available support which means we’re that much closer to using anchor positioning in our production work.

With CSS Anchor Positioning, we can attach an absolutely-positioned element (that we may call a “target”) to another element (that we may call an “anchor”). This makes creating things like tooltips, modals, and pop-ups trivial in CSS, although it can be used for a variety of layouts.

Using anchor positioning, we can attach any two elements, like these, together. It doesn’t even matter where they are in the markup.

<div class="anchor">anchor</div> <div class="target">target</div>

Heads up: Even though the source order does not matter for positioning, it does for accessibility, so it’s a good idea to establish a relationship between the anchor and target using ARIA attributes for better experiences that rely on assistive tech.

CodePen Embed Fallback

We register the .anchor element using the anchor-name property, which takes a dashed ident. We then use that ident to attach the .target to the .anchor using the position-anchor property.

.anchor { anchor-name: --my-anchor; /* the ident */ } .target { position: absolute; position-anchor: --my-anchor; /* attached! */ }

This positions the .target at the center of the .anchor — again, no matter the source order! If we want to position it somewhere else, the simplest way is using the position-area property.

With position-area, we can define a region around the .anchor and place the .target in it. Think of it like drawing a grid of squares that are mapped to the .anchor‘s center, top, right, bottom and left.

For example, if we wish to place the target at the anchor’s top-right corner, we can write…

.target { /* ... */ position-area: top right; } CodePen Embed Fallback

This is just a taste since anchor positioning is a world unto itself. I’d encourage you to read our full guide on it.

Scroll-driven animations

Scroll-driven animations link CSS animations (created from @keyframes) to an element’s scroll position. So instead of running an animation for a given time, the animation will depend on where the user scrolls.

We can link an animation to two types of scroll-driven events:

  • Linking the animation to a scrollable container using the scroll() function.
  • Linking the animation to an element’s position on the viewport using the view() function.

Both of these functions are used inside the animation-timeline, which links the animation progress to the type of timeline we’re using, be it scroll or view. What’s the difference?

With scroll(), the animation runs as the user scrolls the page. The simplest example is one of those reading bars that you might see grow as you read down the page. First, we define our everyday animation and add it to the bar element:

@keyframes grow { from { transform: scaleX(0); } to { transform: scaleX(1); } } .progress { transform-origin: left center; animation: grow linear; }

Note: I am setting transform-origin to left so it the animation progresses from the left instead of expanding from the center.

Then, instead of giving the animation a duration, we can plug it into the scroll position like this:

.progress { /* ... */ animation-timeline: scroll(); }

Assuming you’re using Safari 26 or the latest version of Chrome, the bar grows in width from left to right as you scroll down the viewport.

CodePen Embed Fallback

The view() function is similar, but it bases the animation on the element’s position when it is in view of the viewport. That way, an animation can start or stop at specific points on the page. Here’s an example making images “pop” up as they enter view.

@keyframes popup { from { opacity: 0; transform: translateY(100px); } to { opacity: 1; transform: translateY(0px); } } img { animation: popup linear; }

Then, to make the animation progress as the element enters the viewport, we plug the animation-timeline to view().

img { animation: popup linear; animation-timeline: view(); }

If we leave like this, though, the animation ends just as the element leaves the screen. The user doesn’t see the whole thing! What we want is for the animation to end when the user is in the middle of the viewport so the full timeline runs in view.

This is where we can reach for the animation-range property. It lets us set the animation’s start and end points relative to the viewport. In this specific example, let’s say I want the animation to start when the element enters the screen (i.e., the 0% mark) and finishes a little bit before it reaches the direct center of the viewport (we’ll say 40%):

img { animation: popup linear; animation-timeline: view(); animation-range: 0% 40%; } CodePen Embed Fallback

Once again, scroll-driven animations go way beyond these two basic examples. For a quick intro to all there is to them, I recommend Geoff’s notes.

I feel safer using scroll-drive animations in my production work because it’s more of a progressive enhancement that won’t break an experience even if it is not supported by the browser. Even so, someone may prefer reduced (or no) animation at all, meaning we’d better progressively enhance it anyway with prefers-reduced-motion.

The progress() function

This is another feature we got in Chrome that has made its way to Safari 26. Funny enough, I missed it in Chrome when it released a few months ago, so it makes me twice as happy to see such a handy feature baked into two major browsers.

The progress() function tells you how much a value has progressed in a range between a starting point and an ending point:

progress(<value>, <start>, <end>)

If the <value> is less than the <start>, the result is 0. If the <value> reaches the <end>, the result is 1. Anything in between returns a decimal between 0 and 1.

Technically, this is something we can already do in a calc()-ulation:

calc((value - start) / (end - start))

But there’s a key difference! With progress(), we can calculate values from mixed data types (like adding px to rem), which isn’t currently possible with calc(). For example, we can get the progress value formatted in viewport units from a numeric range formatted in pixels:

progress(100vw, 400px, 1000px);

…and it will return 0 when the viewport is 400px, and as the screen grows to 1000px, it progresses to 1. This means it can typecast different units into a number, and as a consequence, we can transition properties like opacity (which takes a number or percentage) based on the viewport (which is a distance length).

There’s another workaround that accomplishes this using tan() and atan2() functions. I have used that approach before to create smooth viewport transitions. But progress() greatly simplifies the work, making it much more maintainable.

Case in point: We can orchestrate multiple animations as the screen size changes. This next demo takes one of the demos I made for the article about tan() and atan2(), but swaps that out with progress(). Works like a charm!

CodePen Embed Fallback

That’s a pretty wild example. Something more practical might be reducing an image’s opacity as the screen shrinks:

img { opacity: clamp(0.25, progress(100vw, 400px, 1000px), 1); }

Go ahead and resize the demo to update the image’s opacity, assuming you’re looking at it in Safari 26 or the latest version of Chrome.

CodePen Embed Fallback

I’ve clamp()-ed the progress() between 0.25 and 1. But, by default, progress() already clamps the <value> between 0 and 1. According to the WebKit release notes, the current implementation isn’t clamped by default, but upon testing, it does seem to be. So, if you’re wondering why I’m clamping something that’s supposedly clamped already, that’s why.

An unclamped version may come in the future, though.

Self-alignment in absolute positioning

And, hey, check this out! We can align-self and justify-self content inside absolutely-positioned elements. This isn’t as big a deal as the other features we’ve looked at, but it does have a handy use case.

For example, I sometimes want to place an absolutely-positioned element directly in the center of the viewport, but inset-related properties (i.e., top, right, bottom, left, etc.) are relative to the element’s top-left corner. That means we don’t get perfectly centered with something like this as we’d expect:

.absolutely-positioned { position: absolute; top: 50%; left: 50%; }

From here, we could translate the element by half to get things perfectly centered. But now we have the center keyword supported by align-self and justify-self, meaning fewer moving pieces in the code:

.absolutely-positioned { position: absolute; justify-self: center; }

Weirdly enough, I noticed that align-self: center doesn’t seem to center the element relative to the viewport, but instead relative to itself. I found out that can use the anchor-center value to center the element relative to its default anchor, which is the viewport in this specific example:

.absolutely-positioned { position: absolute; align-self: anchor-center; justify-self: center; } CodePen Embed Fallback

And, of course, place-self is a shorthand for the align-self and justify-self properties, so we could combine those for brevity:

.absolutely-positioned { position: absolute; place-self: anchor-center center; } What’s new (for the web)?

Safari 26 isn’t just about keeping up with Chrome. There’s a lot of exciting new stuff in here that we’re getting our hands on for the first time, or that is refined from other browser implementations. Let’s look at those features.

The constrast-color() function

The constrast-color() isn’t new by any means. It’s actually been in Safari Technology Preview since 2021 where it was originally called color-contrast(). In Safari 26, we get the updated naming as well as some polish.

Given a certain color value, contrast-color() returns either white or black, whichever produces a sharper contrast with that color. So, if we were to provide coral as the color value for a background, we can let the browser decide whether the text color is more contrasted with the background as either white or black:

h1 { --bg-color: coral; background-color: var(--bg-color); color: contrast-color(var(--bg-color)); } CodePen Embed Fallback

Our own Daniel Schwarz recently explored the contrast-color() function and found it’s actually not that great at determining the best contrast between colors:

Undoubtedly, the number one shortcoming is that contrast-color() only resolves to either black or white. If you don’t want black or white, well… that sucks.

It sucks because there are cases where neither white nor black produces enough contrast with the provided color to meet WCAG color contrast guidelines. There is an intent to extend contrast-color() so it can return additional color values, but there still would be concerns about how exactly contrast-color() arrives at the “best” color, since we would still need to take into consideration the font’s width, size, and even family. Always check the actual contrast!

So, while it’s great to finally have constrat-color(), I do hope we see improvements added in the future.

Pretty text wrapping

Safari 26 also introduces text-wrap: pretty, which is pretty (get it?) straightforward: it makes paragraphs wrap in a prettier way.

You may remember that Chrome shipped this back in 2023. But take notice that there is a pretty (OK, that’s the last time) big difference between the implementations. Chrome only avoids typographic orphans (short last lines). Safari does more to prettify the way text wraps:

  • Prevents short lines. Avoids single words at the end of the paragraph.
  • Improves rag. Keeps each line relatively the same length.
  • Reduces hyphenation. When enabled, hyphenation improves rag but also breaks words apart. In general, hyphenation should be kept to a minimum.

The WebKit blog gets into much greater detail if you’re curious about what considerations they put into it.

Safari takes additional actions to ensure “pretty” text wrapping, including the overall ragging along the text. This is just the beginning!

I think these are all the CSS features coming to Safari that you should look out for, but I don’t want you to think they are the only features in the release. As I mentioned at the top, we’re talking about 75 new Web Platform features, including HDR Images, support for SVG favicons, logical property support for overflow properties, margin trimming, and much, much more. It’s worth perusing the full release notes.

Touring New CSS Features in Safari 26 originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Recreating Gmail’s Google Gemini Animation

Css Tricks - Fri, 09/26/2025 - 4:45am

I always see this Google Gemini button up in the corner in Gmail. When you hover over it, it does this cool animation where the little four-pointed star spins and the outer shape morphs between a couple different shapes that are also spinning.

I challenged myself to recreate the button using the new CSS shape() function sprinkled with animation to get things pretty close. Let me walk you through it.

Drawing the Shapes

Breaking it down, we need five shapes in total:

  1. Four-pointed star
  2. Flower-ish thing (yes, that’s the technical term)
  3. Cylinder-ish thing (also the correct technical term)
  4. Rounded hexagon
  5. Circle

I drew these shapes in a graphics editing program (I like Affinity Designer, but any app that lets you draw vector shapes should work), outputted them in SVG, and then used a tool, like Temani Afif’s generator, to translate the SVG paths the program generated to the CSS shape() syntax.

Now, before I exported the shapes from Affinity Designer, I made sure the flower, hexagon, circle, and cylinder all had the same number of anchor points. If they don’t have the same number, then the shapes will jump from one to the next and won’t do any morphing. So, let’s use a consistent number of anchor points in each shape — even the circle — and we can watch these shapes morph into each other.

I set twelve anchor points on each shape because that was the highest amount used (the hexagon had two points near each curved corner).

Something related (and possibly hard to solve, depending on your graphics program) is that some of my shapes were wildly contorted when animating between shapes. For example, many shapes became smaller and began spinning before morphing into the next shape, while others were much more seamless. I eventually figured out that the interpolation was matching each shape’s starting point and continued matching points as it followed the shape.

The result is that the matched points move between shapes, so if the starting point for one shape is on opposite side of the starting point of the second shape, a lot of movement is necessary to transition from one shape’s starting point to the next shape’s starting point.

CodePen Embed Fallback

Luckily, the circle was the only shape that gave me trouble, so I was able to spin it (with some trial and error) until its starting point more closely matched the other starting points.

Another issue I ran into was that the cylinder-ish shape had two individual straight lines in shape() with line commands rather than using the curve command. This prevented the animation from morphing into the next shape. It immediately snapped to the next image without animating the transition, skipping ahead to the next shape (both when going into the cylinder and coming out of it).

I went back into Affinity Designer and ever-so-slightly added curvature to the two lines, and then it morphed perfectly. I initially thought this was a shape() quirk, but the same thing happened when I attempted the animation with the path() function, suggesting it’s more an interpolation limitation than it is a shape() limitation.

Once I finished adding my shape() values, I defined a CSS variable for each shape. This makes the later uses of each shape() more readable, not to mention easier to maintain. With twelve lines per shape the code is stinkin’ long (technical term) so we’ve put it behind an accordion menu.

View Shape Code :root { --hexagon: shape( evenodd from 6.47% 67.001%, curve by 0% -34.002% with -1.1735% -7.7% / -1.1735% -26.302%, curve by 7.0415% -12.1965% with 0.7075% -4.641% / 3.3765% -9.2635%, curve by 29.447% -17.001% with 6.0815% -4.8665% / 22.192% -14.1675%, curve by 14.083% 0% with 4.3725% -1.708% / 9.7105% -1.708%, curve by 29.447% 17.001% with 7.255% 2.8335% / 23.3655% 12.1345%, curve by 7.0415% 12.1965% with 3.665% 2.933% / 6.334% 7.5555%, curve by 0% 34.002% with 1.1735% 7.7% / 1.1735% 26.302%, curve by -7.0415% 12.1965% with -0.7075% 4.641% / -3.3765% 9.2635%, curve by -29.447% 17.001% with -6.0815% 4.8665% / -22.192% 14.1675%, curve by -14.083% 0% with -4.3725% 1.708% / -9.7105% 1.708%, curve by -29.447% -17.001% with -7.255% -2.8335% / -23.3655% -12.1345%, curve by -7.0415% -12.1965% with -3.665% -2.933% / -6.334% -7.5555%, close ); --flower: shape( evenodd from 17.9665% 82.0335%, curve by -12.349% -32.0335% with -13.239% -5.129% / -18.021% -15.402%, curve by -0.0275% -22.203% with -3.1825% -9.331% / -3.074% -16.6605%, curve by 12.3765% -9.8305% with 2.3835% -4.3365% / 6.565% -7.579%, curve by 32.0335% -12.349% with 5.129% -13.239% / 15.402% -18.021%, curve by 20.4535% -0.8665% with 8.3805% -2.858% / 15.1465% -3.062%, curve by 11.58% 13.2155% with 5.225% 2.161% / 9.0355% 6.6475%, curve by 12.349% 32.0335% with 13.239% 5.129% / 18.021% 15.402%, curve by 0.5715% 21.1275% with 2.9805% 8.7395% / 3.0745% 15.723%, curve by -12.9205% 10.906% with -2.26% 4.88% / -6.638% 8.472%, curve by -32.0335% 12.349% with -5.129% 13.239% / -15.402% 18.021%, curve by -21.1215% 0.5745% with -8.736% 2.9795% / -15.718% 3.0745%, curve by -10.912% -12.9235% with -4.883% -2.2595% / -8.477% -6.6385%, close ); --cylinder: shape( evenodd from 10.5845% 59.7305%, curve by 0% -19.461% with -0.113% -1.7525% / -0.11% -18.14%, curve by 10.098% -26.213% with 0.837% -10.0375% / 3.821% -19.2625%, curve by 29.3175% -13.0215% with 7.2175% -7.992% / 17.682% -13.0215%, curve by 19.5845% 5.185% with 7.1265% 0% / 13.8135% 1.887%, curve by 9.8595% 7.9775% with 3.7065% 2.1185% / 7.035% 4.8195%, curve by 9.9715% 26.072% with 6.2015% 6.933% / 9.4345% 16.082%, curve by 0% 19.461% with 0.074% 1.384% / 0.0745% 17.7715%, curve by -13.0065% 29.1155% with -0.511% 11.5345% / -5.021% 21.933%, curve by -26.409% 10.119% with -6.991% 6.288% / -16.254% 10.119%, curve by -20.945% -5.9995% with -7.6935% 0% / -14.8755% -2.199%, curve by -8.713% -7.404% with -3.255% -2.0385% / -6.1905% -4.537%, curve by -9.7575% -25.831% with -6.074% -6.9035% / -9.1205% -15.963%, close ); --star: shape( evenodd from 50% 24.787%, curve by 7.143% 18.016% with 0% 0% / 2.9725% 13.814%, curve by 17.882% 7.197% with 4.171% 4.2025% / 17.882% 7.197%, curve by -17.882% 8.6765% with 0% 0% / -13.711% 4.474%, curve by -7.143% 16.5365% with -4.1705% 4.202% / -7.143% 16.5365%, curve by -8.6115% -16.5365% with 0% 0% / -4.441% -12.3345%, curve by -16.4135% -8.6765% with -4.171% -4.2025% / -16.4135% -8.6765%, curve by 16.4135% -7.197% with 0% 0% / 12.2425% -2.9945%, curve by 8.6115% -18.016% with 4.1705% -4.202% / 8.6115% -18.016%, close ); --circle: shape( evenodd from 13.482% 79.505%, curve by -7.1945% -12.47% with -1.4985% -1.8575% / -6.328% -10.225%, curve by 0.0985% -33.8965% with -4.1645% -10.7945% / -4.1685% -23.0235%, curve by 6.9955% -12.101% with 1.72% -4.3825% / 4.0845% -8.458%, curve by 30.125% -17.119% with 7.339% -9.1825% / 18.4775% -15.5135%, curve by 13.4165% 0.095% with 4.432% -0.6105% / 8.9505% -0.5855%, curve by 29.364% 16.9% with 11.6215% 1.77% / 22.102% 7.9015%, curve by 7.176% 12.4145% with 3.002% 3.7195% / 5.453% 7.968%, curve by -0.0475% 33.8925% with 4.168% 10.756% / 4.2305% 22.942%, curve by -7.1135% 12.2825% with -1.74% 4.4535% / -4.1455% 8.592%, curve by -29.404% 16.9075% with -7.202% 8.954% / -18.019% 15.137%, curve by -14.19% -0.018% with -4.6635% 0.7255% / -9.4575% 0.7205%, curve by -29.226% -16.8875% with -11.573% -1.8065% / -21.9955% -7.9235%, close ); }

If all that looks like gobbledygook to you, it largely does to me too (and I wrote the shape() Almanac entry). As I said above, I converted them from stuff I drew to shape()s with a tool. If you can recognize the shapes from the custom property names, then you’ll have all you need to know to keep following along.

Breaking Down the Animation

After staring at the Gmail animation for longer than I would like to admit, I was able to recognize six distinct phases:

First, on hover:

  1. The four-pointed star spins to the right and changes color.
  2. The fancy blue shape spreads out from underneath the star shape.
  3. The fancy blue shape morphs into another shape while spinning.
  4. The purplish color is wiped across the fancy blue shape.

Then, after hover:

  1. The fancy blue shape contracts (basically the reverse of Phase 2).
  2. The four-pointed star spins left and returns to its initial color (basically the reverse of Phase 1).

That’s the run sheet we’re working with! We’ll write the CSS for all that in a bit, but first I’d like to set up the HTML structure that we’re hooking into.

The HTML

I’ve always wanted to be one of those front-enders who make jaw-dropping art out of CSS, like illustrating the Sistine chapel ceiling with a single div (cue someone commenting with a CodePen doing just that). But, alas, I decided I needed two divs to accomplish this challenge, and I thank you for looking past my shame. To those of you who turned up your nose and stopped reading after that admission: I can safely call you a Flooplegerp and you’ll never know it.

(To those of you still with me, I don’t actually know what a Flooplegerp is. But I’m sure it’s bad.)

Because the animation needs to spread out the blue shape from underneath the star shape, they need to be two separate shapes. And we can’t shrink or clip the main element to do this because that would obscure the star.

So, yeah, that’s why I’m reaching for a second div: to handle the fancy shape and how it needs to move and interact with the star shape.

<div id="geminianimation"> <div></div> </div> The Basic CSS Styling

Each shape is essentially defined with the same box with the same dimensions and margin spacing.

#geminianimation { width: 200px; aspect-ratio: 1/1; margin: 50px auto; position: relative; }

We can clip the box to a particular shape using a pseudo-element. For example, let’s clip a star shape using the CSS variable (--star) we defined for it and set a background color on it:

#geminianimation { width: 200px; aspect-ratio: 1; margin: 50px auto; position: relative; &::before { content: ""; clip-path: var(--star); width: 100%; height: 100%; position: absolute; background-color: #494949; } } CodePen Embed Fallback

We can hook into the container’s child div and use it to establish the animation’s starting shape, which is the flower (clipped with our --flower variable):

#geminianimation div { width: 100%; height: 100%; clip-path: var(--flower); background: linear-gradient(135deg, #217bfe, #078efb, #ac87eb, #217bfe); }

What we get is a star shape stacked right on top of a flower shape. We’re almost done with our initial CSS, but in order to recreate the animated color wipes, we need a much larger surface that allows us to “change” colors by moving the background gradient’s position. Let’s move the gradient so that it is declared on a pseudo element instead of the child div, and size it up by 400% to give us additional breathing room.

#geminianimation div { width: 100%; height: 100%; clip-path: var(--flower); &::after { content: ""; background: linear-gradient(135deg, #217bfe, #078efb, #ac87eb, #217bfe); width: 400%; height: 400%; position: absolute; } }

Now we can clearly see how the shapes are positioned relative to each other:

CodePen Embed Fallback Animating Phases 1 and 6

Now, I’ll admit, in my own hubris, I’ve turned up my very own schnoz at the humble transition property because my thinking is typically, Transitions are great for getting started in animation and for quick things, but real animations are done with CSS keyframes. (Perhaps I, too, am a Flooplegerp.)

But now I see the error of my ways. I can write a set of keyframes that rotate the star 180 degrees, turn its color white(ish), and have it stay that way for as long as the element is hovered. What I can’t do is animate the star back to what it was when the element is un-hovered.

I can, however, do that with the transition property. To do this, we add transition: 1s ease-in-out; on the ::before, adding the new background color and rotating things on :hover over the #geminianimation container. This accounts for the first and sixth phases of the animation we outlined earlier.

#geminianimation { &::before { /* Existing styles */ transition: 1s ease-in-out; } &:hover { &::before { transform: rotate(180deg); background-color: #FAFBFE; } } } Animating Phases 2 and 5

We can do something similar for the second and fifth phases of the animation since they are mirror reflections of each other. Remember, in these phases, we’re spreading and contracting the fancy blue shape.

We can start by shrinking the inner div’s scale to zero initially, then expand it back to its original size (scale: 1) on :hover (again using transitions):

#geminianimation { div { scale: 0; transition: 1s ease-in-out; } &:hover { div { scale: 1; } } CodePen Embed Fallback Animating Phase 3

Now, we very well could tackle this with a transition like we did the last two sets, but we probably should not do it… that is, unless you want to weep bitter tears and curse the day you first heard of CSS… not that I know from personal experience or anything… ha ha… ha.

CSS keyframes are a better fit here because there are multiple states to animate between that would require defining and orchestrating several different transitions. Keyframes are more adept at tackling multi-step animations.

What we’re basically doing is animating between different shapes that we’ve already defined as CSS variables that clip the shapes. The browser will handle interpolating between the shapes, so all we need is to tell CSS which shape we want clipped at each phase (or “section”) of this set of keyframes:

@keyframes shapeshift { 0% { clip-path: var(--circle); } 25% { clip-path: var(--flower); } 50% { clip-path: var(--cylinder); } 75% { clip-path: var(--hexagon); } 100% { clip-path: var(--circle); } }

Yes, we could combine the first and last keyframes (0% and 100%) into a single step, but we’ll need them separated in a second because we also want to animate the rotation at the same time. We’ll set the initial rotation to 0turn and the final rotation 1turn so that it can keep spinning smoothly as long as the animation is continuing:

@keyframes shapeshift { 0% { clip-path: var(--circle); rotate: 0turn; } 25% { clip-path: var(--flower); } 50% { clip-path: var(--cylinder); } 75% { clip-path: var(--hexagon); } 100% { clip-path: var(--circle); rotate: 1turn; } }

Note: Yes, turn is indeed a CSS unit, albeit one that often goes overlooked.

We want the animation to be smooth as it interpolates between shapes. So, I’m setting the animation’s timing function with ease-in-out. Unfortunately, this will also slow down the rotation as it starts and ends. However, because we’re both beginning and ending with the circle shape, the fact that the rotation slows coming out of 0% and slows again as it heads into 100% is not noticeable — a circle looks like a circle no matter its rotation. If we were ending with a different shape, the easing would be visible and I would use two separate sets of keyframes — one for the shape-shift and one for the rotation — and call them both on the #geminianimation child div .

#geminianimation:hover { div { animation: shapeshift 5s ease-in-out infinite forwards; } } Animating Phase 4

That said, we still do need one more set of keyframes, specifically for changing the shape’s color. Remember how we set a linear gradient on the parent container’s ::after pseudo, then we also increased the pseudo’s width and height? Here’s that bit of code again:

#geminianimation div { width: 100%; height: 100%; clip-path: var(--flower); &::after { content: ""; background: linear-gradient(135deg, #217bfe, #078efb, #ac87eb, #217bfe); width: 400%; height: 400%; position: absolute; } }

The gradient is that large because we’re only showing part of it at a time. And that means we can translate the gradient’s position to move the gradient at certain keyframes. 400% can be nicely divided into quarters, so we can move the gradient by, say, three-quarters of its size. Since its parent, the #geminianimation div, is already spinning, we don’t need any fancy movements to make it feel like the color is coming from different directions. We just translate it linearly and the spin adds some variability to what direction the color wipe comes from.

@keyframes gradientMove { 0% { translate: 0 0; } 100% { translate: -75% -75%; } } One final refinement

Instead of using the flower as the default shape, let’s change it to circle. This smooths things out when the hover interaction causes the animation to stop and return to its initial position.

And there you have it:

CodePen Embed Fallback Wrapping up

We did it! Is this exactly how Google accomplished the same thing? Probably not. In all honesty, I never inspected the animation code because I wanted to approach it from a clean slate and figure out how I would do it purely in CSS.

That’s the fun thing about a challenge like this: there are different ways to accomplish the same thing (or something similar), and your way of doing it is likely to be different than mine. It’s fun to see a variety of approaches.

Which leads me to ask: How would you have approached the Gemini button animation? What considerations would you take into account that maybe I haven’t?

Recreating Gmail’s Google Gemini Animation originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

CSS Typed Arithmetic

Css Tricks - Wed, 09/24/2025 - 2:49am

CSS typed arithmetic is genuinely exciting! It opens the door to new kinds of layout composition and animation logic we could only hack before. The first time I published something that leaned on typed arithmetic was in this animation:

CodePen Embed Fallback

But before we dive into what is happening in there, let’s pause and get clear on what typed arithmetic actually is and why it matters for CSS.

Browser Support: The CSS feature discussed in this article, typed arithmetic, is on the cutting edge. As of the time of writing, browser support is very limited and experimental. To ensure all readers can understand the concepts, the examples throughout this article are accompanied by videos and images, demonstrating the results for those whose browsers do not yet support this functionality. Please check resources like MDN or Can I Use for the latest support status.

The Types

If you really want to get what a “type” is in CSS, think about TypeScript. Now forget about TypeScript. This is a CSS article, where semantics actually matter.

In CSS, a type describes the unit space a value lives in, and is called a data-type. Every CSS value belongs to a specific type, and each CSS property and function only accepts the data type (or types) it expects.

  • Properties like opacity or scale use a plain <number> with no units.
  • width, height, other box metrics, and many additional properties use <length> units like px, rem, cm, etc.
  • Functions like rotate() or conic-gradient() use an <angle> with deg, rad, or turn.
  • animation and transition use <time> for their duration in seconds (s) or milliseconds (ms).

Note: You can identify CSS data types in the specs, on MDN, and other official references by their angle brackets: <data-type>.

There are many more data types like <percentage>, <frequency>, and <resolution>, but the types mentioned above cover most of our daily use cases and are all we will need for our discussion today. The mathematical concept remains the same for (almost) all types.

I say “almost” all types for one reason: not every data type is calculable. For instance, types like <color>, <string>, or <image> cannot be used in mathematical operations. An expression like "foo" * red would be meaningless. So, when we discuss mathematics in general, and typed arithmetic in particular, it is crucial to use types that are inherently calculable, like <length>, <angle>, or <number>.

The Rules of Typed Arithmetic

Even when we use calculable data types, there are still limitations and important rules to keep in mind when performing mathematical operations on them.

Addition and Subtraction

Sadly, a mix-and-match approach doesn’t really work here. Expressions like calc(3em + 45deg) or calc(6s - 3px) will not produce a logical result. When adding or subtracting, you must stick to the same data type.

Of course, you can add and subtract different units within the same type, like calc(4em + 20px) or calc(300deg - 1rad).

Multiplication

With multiplication, you can only multiply by a plain <number> type. For example: calc(3px * 7), calc(10deg * 6), or calc(40ms * 4). The result will always adopt the type and unit of the first value, with the new value being the product of the multiplication.

But why can you only multiply by a number? If we tried something like calc(10px * 10px) and assumed it followed “regular” math, we would expect a result of 100px². However, there are no squared pixels in CSS, and certainly no square degrees (though that could be interesting…). Because such a result is invalid, CSS only permits multiplying typed values by unitless numbers.

Division

Here, too, mixing and matching incompatible types is not allowed, and you can divide by a number just as you can multiply a number. But what happens when you divide a type by the same type?

Hint: this is where things get interesting.

Again, if we were thinking in terms of regular math, we would expect the units to cancel each other out, leaving only the calculated value. For example, 90x / 6x = 15. In CSS, however, this isn’t the case. Sorry, it wasn’t the case.

Previously, an expression like calc(70px / 10px) would have been invalid. But starting with Safari 18.2 and Chrome 140 (and hopefully soon in all other browsers), this expression now returns a valid number, which winds up being 7 in this case. This is the major change that typed arithmetic enables.

Is that all?!

That little division? Is that the big thing I called “genuinely exciting”? Yes! Because this one little feature opens the door to a world of creative possibilities. Case in point: we can convert values from one data type to another and mathematically condition values of one type based on another, just like in the swirl example I demoed at the top.

So, to understand what is happening there, let’s look at a more simplified swirl:

CodePen Embed Fallback

I have a container<div> with 36 <i> elements in the markup that are arranged in a spiral with CSS. Each element has an angle relative to the center point, rotate(var(--angle)), and a distance from that center point, translateX(var(--distance)).

The angle calculation is quite direct. I take the index of each <i> element using sibling-index() and multiply it by 10deg. So, the first element with an index of 1 will be rotated by 10 degrees (1 * 10deg), the second by 20 degrees (2 * 10deg), the third by 30 degrees (3 * 10deg), and so on.

i { --angle: calc(sibling-index() * 10deg); }

As for the distance, I want it to be directly proportional to the angle. I first use typed arithmetic to divide the angle by 360 degrees: var(--angle) / 360deg.

This returns the angle’s value, but as a unitless number, which I can then use anywhere. In this case, I can multiply it by a <length> value (e.g. 180px) that determines the element’s distance from the center point.

i { --angle: calc(sibling-index() * 10deg); --distance: calc(var(--angle) / 360deg * 180px); }

This way, the ratio between the angle and the distance remains constant. Even if we set the angle of each element differently, or to a new value, the elements will still align on the same spiral.

The Importance of the Divisor’s Unit

It’s important to clarify that when using typed arithmetic this way, you get a unitless number, but its value is relative to the unit of the divisor.

In our simplified spiral, we divided the angle by 360deg. The resulting unitless number, therefore, represents the value in degrees. If we had divided by 1turn instead, the result would be completely different — even though 1turn is equivalent to 360deg, the resulting unitless number would represent the value in turns.

A clearer example can be seen with <length> values.

Let’s say we are working with a screen width of 1080px. If we divide the screen width (100vw) by 1px, we get the number of pixels that fit into the screen width, which is, of course, 1080.

calc(100vw / 1px) /* 1080 */

However, if we divide that same width by 1em (and assume a font size of 16px), we get the number of em units that fit across the screen.

calc(100vw / 1em) /* 67.5 */

The resulting number is unitless in both cases, but its meaning is entirely dependent on the unit of the value we divided by.

From Length to Angle

Of course, this conversion doesn’t have to be from a type <angle> to a type <length>. Here is an example that calculates an element’s angle based on the screen width (100vw), creating a new and unusual kind of responsiveness.

CodePen Embed Fallback

And get this: There are no media queries in here! it’s all happening in a single line of CSS doing the calculations.

To determine the angle, I first define the width range I want to work within. clamp(300px, 100vw, 700px) gives me a closed range of 400px, from 300px to 700px. I then subtract 700px from this range, which gives me a new range, from -400px to 0px.

Using typed arithmetic, I then divide this range by 400px, which gives me a normalized, unitless number between -1 and 0. And finally, I convert this number into an <angle> by multiplying it by -90deg.

Here’s what that looks like in CSS when we put it all together:

p { rotate: calc(((clamp(300px, 100vw, 700px) - 700px) / 400px) * -90deg); } From Length to Opacity

Of course, the resulting unitless number can be used as-is in any property that accepts a <number> data type, such as opacity. What if I want to determine the font’s opacity based on its size, making smaller fonts more opaque and therefore clearer? Is it possible? Absolutely.

CodePen Embed Fallback

In this example, I am setting a different font-size value for each <p> element using a --font-size custom property. and since the range of this variable is from 0.8rem to 2rem, I first subtract 0.8rem from it to create a new range of 0 to 1.2rem.

I could divide this range by 1.2rem to get a normalized, unitless value between 0 and 1. However, because I don’t want the text to become fully transparent, I divide it by twice that amount (2.4rem). This gives me a result between 0 and 0.5, which I then subtract from the maximum opacity of 1.

p { font-size: var(--font-size, 1rem); opacity: calc(1 - (var(--font-size, 1rem) - 0.8rem) / 2.4rem); }

Notice that I am displaying the font size in pixel units even though the size is defined in rem units. I simply use typed arithmetic to divide the font size by 1px, which gives me the size in pixels as a unitless value. I then inject this value into the content of the the paragraph’s ::after pseudo-element.

p::after { counter-reset: px calc(var(--font-size, 1rem) / 1px); content: counter(px) 'px'; } Dynamic Width Colors

Of course, the real beauty of using native CSS math functions, compared to other approaches, is that everything happens dynamically at runtime. Here, for example, is a small demo where I color the element’s background relative to its rendered width.

p { --hue: calc(100cqi / 1px); background-color: hsl(var(--hue, 0) 75% 25%); }

You can drag the bottom-right corner of the element to see how the color changes in real-time.

CodePen Embed Fallback

Here’s something neat about this demo: because the element’s default width is 50% of the screen width and the color is directly proportional to that width, it’s possible that the element will initially appear in completely different colors on different devices with different screens. Again, this is all happening without any media queries or JavaScript.

An Extreme Example: Chaining Conversions

OK, so we’ve established that typed arithmetic is cool and opens up new and exciting possibilities. Before we put a bow on this, I wanted to pit this concept against a more extreme example. I tried to imagine what would happen if we took a <length> type, converted it to a <number> type, then to an <angle> type, back to a <number> type, and, from there, back to a <length> type.

Phew!

I couldn’t find a real-world use case for such a chain, but I did wonder what would happen if we were to animate an element’s width and use that width to determine the height of something else. All the calculations might not be necessary (maybe?), but I think I found something that looks pretty cool.

CodePen Embed Fallback

In this demo, the animation is on the solid line along the bottom. The vertical position of the ball, i.e. its height, relative to the line, is proportional to the line’s width. So, as the line expands and contracts, so does the path of the bouncing ball.

To create the parabolic arc that the ball moves along, I take the element’s width (100cqi) and, using typed arithmetic, divide it by 300px to get a unitless number between 0 and 1. I multiply that by 180deg to get an angle that I use in a sin() function (Juan Diego has a great article on this), which returns another unitless number between 0 and 1, but with a parabolic distribution of values.

Finally, I multiply this number by -200px, which outputs the ball’s vertical position relative to the line.

.ball { --translateY: calc(sin(calc(100cqi / 300px) * 180deg) * -200px) ; translate: -50% var(--translateY, 0); }

And again, because the ball’s position is relative to the line’s width, the ball’s position will remain on the same arc, no matter how we define that width.

Wrapping Up: The Dawn of Computational CSS

The ability to divide one typed value by another to produce a unitless number might seem like no big deal; more like a minor footnote in the grand history of CSS.

But as we’ve seen, this single feature is a quiet revolution. It dismantles the long-standing walls between different CSS data types, transforming them from isolated silos into a connected, interoperable system. We’ve moved beyond simple calculations, and entered the era of true Computational CSS.

This isn’t just about finding new ways to style a button or animate a loading spinner. It represents a fundamental shift in our mental model. We are no longer merely declaring static styles, but rather defining dynamic, mathematical relationships between properties. The width of an element can now intrinsically know about its color, an angle can dictate a distance, and a font’s size can determine its own visibility.

This is CSS becoming self-aware, capable of creating complex behaviors and responsive designs that adapt with a precision and elegance that previously required JavaScript.

So, the next time you find yourself reaching for JavaScript to bridge a gap between two CSS properties, pause for a moment. Ask yourself if there’s a mathematical relationship you can define instead. You might be surprised at how far you can go with just a few lines of CSS.

The Future is Calculable

The examples in this article are just the first steps into a much larger world. What happens when we start mixing these techniques with scroll-driven animations, view transitions, and other modern CSS features? The potential for creating intricate data visualizations, generative art, and truly fluid user interfaces, all natively in CSS, is immense. We are being handed a new set of creative tools, and the instruction manual is still being written.

CSS Typed Arithmetic originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

On inclusive personas and inclusive user research

Css Tricks - Fri, 09/19/2025 - 3:58am

I’m inclined to take a few notes on Eric Bailey’s grand post about the use of inclusive personas in user research. As someone who has been in roles that have both used and created user personas, there’s so much in here

What’s the big deal, right? We’re often taught and encouraged to think about users early in the design process. It’s user’ centric design, so let’s personify 3-4 of the people we think represent our target audiences so our work is aligned with their objectives and needs. My master’s program was big on that and went deep into different approaches, strategies, and templates for documenting that research.

And, yes, it is research. The idea, in theory, is that by understanding the motivations and needs of specific users (gosh, isn’t “users” an awkward term?), we can “design backwards” so that the end goal is aligned to actions that get them there.

Eric sees holes in that process, particularly when it comes to research centered around inclusiveness. Why is that? Very good reasons that I’m compiling here so I can reference it later. There’s a lot to take in, so you’d do yourself a solid by reading Eric’s post in full. Your takeaways may be different than mine.

Traditional vs. Inclusive user research

First off, I love how Eric distinguishes what we typically refer to as the general type of user personas, like the ones I made to generalize an audience, from inclusive user personas that are based on individual experiences.

Inclusive user research practices are different than a lot of traditional user research. While there is some high-level overlap in approach, know the majority of inclusive user research is more focused on the individual experience and less about more general trends of behavior.

So, right off the bat we have to reframe what we’re talking about. There’s blanket personas that are placeholders for abstracting what we think we know about specific groups of people versus individual people that represent specific experiences that impact usability and access to content.

A primary goal in inclusive user research is often to identify concrete barriers that prevent someone from accessing the content they want or need. While the techniques people use are varied, these barriers represent insurmountable obstacles that stymie a whole host of navigation techniques and approaches.

If you’re looking for patterns, trends, and customer insights, know that what you want is regular user testing. Here, know that the same motivating factors you’re looking to uncover also exist for disabled people. This is because they’re also, you know, people.

Assistive technology is not exclusive to disabilities

It’s so easy to assume that using assistive tools automatically means accommodating a disability or impairment, but that’s not always the case. Choice points from Eric:

  • First is that assistive technology is a means, and not an end.
  • Some disabled people use more than one form of assistive technology, both concurrently and switching them in and out as needed.
  • Some disabled people don’t use assistive technology at all.
  • Not everyone who uses assistive technology has also mastered it.
  • Disproportionate attention placed on one kind of assistive technology at the expense of others.
  • It’s entirely possible to have a solution that is technically compliant, yet unintuitive or near-impossible to use in the actual. 

I like to keep in mind that assistive technologies are for everyone. I often think about examples in the physical world where everyone benefits from an accessibility enhancement, such as cutting curbs in sidewalks (great for skateboarders!), taking elevators (you don’t have to climb stairs in some cases), and using TV subtitles (I often have to keep the volume low for sleeping kids).

That’s the inclusive part of this. Everyone benefits rather than a specific subset of people.

Different personas, different priorities

What happens when inclusive research is documented separately from general user research?

Another folly of inclusive personas is that they’re decoupled from regular personas. This means they’re easily dismissible as considerations.

[…]

Disability is diversity, and the plain and honest truth is that diversity is missing from your personas if disability conditions are not present in at least some of them. This, in turn, means your personas are misrepresentative of the people in the abstract you claim to serve.

In practice, that means:

[…] we also want to hold space for things that need direct accessibility support and remediation when this consideration of accessibility fails to happen. It’s all about approach.

An example of how to consider your approach is when adding drag and drop support to an experience. […] [W]e want to identify if drag and drop is even needed to achieve the outcome the organization needs.

Thinking of a slick new feature that will impress your users? Great! Let’s make sure it doesn’t step on the toes of other experiences in the process, because that’s antithetical to inclusiveness. I recognize this temptation in my own work, particularly if I land on a novel UI pattern that excites me. The excitement and tickle I get from a “clever” idea gives me a blind side to evaluating the overall effectiveness of it.

Radical participatory design

Gosh dang, why didn’t my schoolwork ever cover this! I had to spend a little time reading the Cambridge University Press article explaining radical participatopry design (RPD) that Eric linked up.

Therefore, we introduce the term RPD to differentiate and represent a type of PD that is participatory to the root or core: full inclusion as equal and full members of the research and design team. Unlike other uses of the term PD, RPD is not merely interaction, a method, a way of doing a method, nor a methodology. It is a meta-methodology, or a way of doing a methodology. 

Ah, a method for methodology! We’re talking about not only including community members into the internal design process, but make them equal stakeholders as well. They get the power to make decisions, something the article’s author describes as a form of decolonization.

Or, as Eric nicely describes it:

Existing power structures are flattened and more evenly distributed with this approach.

Bonus points for surfacing the model minority theory:

The term “model minority” describes a minority group that society regards as high-performing and successful, especially when compared to other groups. The narrative paints Asian American children as high-achieving prodigies, with fathers who practice medicine, science, or law and fierce mothers who force them to work harder than their classmates and hold them to standards of perfection.

It introduces exclusiveness in the quest to pursue inclusiveness — a stereotype within a stereotype.

Thinking bigger

Eric caps things off with a great compilation of actionable takeaways for avoiding the pitfalls of inclusive user personas:

  • Letting go of control leads to better outcomes.
  • Member checking: letting participants review, comment on, and correct the content you’ve created based on their input.
  • Take time to scrutinize the functions of our roles and how our organizations compel us to undertake them in order to be successful within them.
  • Organizations can turn inwards and consider the artifacts their existing design and research processes produce. They can then identify opportunities for participants to provide additional clarity and corrections along the way.

On inclusive personas and inclusive user research originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Is it Time to Un-Sass?

Css Tricks - Wed, 09/17/2025 - 4:02am

Several weeks ago, I participated in Front End Study Hall. Front End Study Hall is an HTML and CSS focused meeting held on Zoom every two weeks. It is an opportunity to learn from one another as we share our common interest in these two building blocks of the Web. Some weeks, there is more focused discussion while other weeks are more open ended and members will ask questions or bring up topics of interest.

Joe, the moderator of the group, usually starts the discussion with something he has been thinking about. In this particular meeting, he asked us about Sass. He asked us if we used it, if we liked it, and then to share our experience with it. I had planned to answer the question but the conversation drifted into another topic before I had the chance to answer. I saw it as an opportunity to write and to share some of the things that I have been thinking about recently.

Beginnings

I started using Sass in March 2012. I had been hearing about it through different things I read. I believe I heard Chris Coyier talk about it on his then-new podcast, ShopTalk Show. I had been interested in redesigning my personal website and I thought it would be a great chance to learn Sass. I bought an e-book version of Pragmatic Guide to Sass and then put what I was learning into practice as I built a new version of my website. The book suggested using Compass to process my Sass into CSS. I chose to use SCSS syntax instead of indented syntax because SCSS was similar to plain CSS. I thought it was important to stay close to the CSS syntax because I might not always have the chance to use Sass, and I wanted to continue to build my CSS skills.

It was very easy to get up and running with Sass. I used a GUI tool called Scout to run Compass. After some frustration trying to update Ruby on my computer, Scout gave me an environment to get up and going quickly. I didn’t even have to use the command line. I just pressed “Play” to tell my computer to watch my files. Later I learned how to use Compass through the command line. I liked the simplicity of that tool and wish that at least one of today’s build tools incorporated that same simplicity.

I enjoyed using Sass out of the gate. I liked that I was able to create reusable variables in my code. I could set up colors and typography and have consistency across my code. I had not planned on using nesting much but after I tried it, I was hooked. I really liked that I could write less code and manage all the relationships with nesting. It was great to be able to nest a media query inside a selector and not have to hunt for it in another place in my code.

Fast-forward a bit…

After my successful first experience using Sass in a personal project, I decided to start using it in my professional work. And I encouraged my teammates to embrace it. One of the things I liked most about Sass was that you could use as little or as much as you liked. I was still writing CSS but now had the superpower that the different helper functions in Sass enabled.

I did not get as deep into Sass as I could have. I used the Sass @extend rule more in the beginning. There are a lot of features that I did not take advantage of, like placeholder selectors and for loops. I have never been one to rely much on shortcuts. I use very few of the shortcuts on my Mac. I have dabbled in things like Emmet but tend to quickly abandon them because I am just use to writing things out and have not developed the muscle memory of using shortcuts.

Is it time to un-Sass?

By my count, I have been using Sass for over 13 years. I chose Sass over Less.js because I thought it was a better direction to go at the time. And my bet paid off. That is one of the difficult things about working in the technical space. There are a lot of good tools but some end up rising to the top and others fall away. I have been pretty fortunate that most of the decisions I have made have gone the way that they have. All the agencies I have worked for have used Sass.

At the beginning of this year, I finally jumped into building a prototype for a personal project that I have been thinking about for years: my own memory keeper. One of the few things that I liked about Facebook was the Memories feature. I enjoyed visiting that page each day to remember what I had been doing on that specific day in years past. But I felt at times that Facebook was not giving me all of my memories. And my life doesn’t just happen on Facebook. I also wanted a way to view memories from other days besides just the current date.

As I started building my prototype, I wanted to keep it simple. I didn’t want to have to set up any build tools. I decided to write CSS without Sass.

Okay, so that was my intention. But I soon realized that that I was using nesting. I had been working on it a couple of days before I realized it.

But my code was working. That is when I realized that the native nesting in CSS works much the same nesting in Sass. I had followed the discussion about implementing nesting in native CSS. At one point, the syntax was going to be very different. To be honest, I lost track of where things had landed because I was continuing to use Sass. Native CSS nesting was not a big concern to me right then.

I was amazed when I realized that nesting works just the same way. And it was in that moment that I began to wonder:

Is this finally the time to un-Sass?

I want to give credit where credit is due. I’m borrowing the term “un-Sass” from Stu Robson, who is actually in the middle of writing a series called “Un-Sass’ing my CSS” as I started thinking about writing this post. I love the term “un-Sass” because it is easy to remember and so spot on to describe what I have been thinking about.

Here is what I am taking into consideration:

Custom Properties

I knew that a lot about what I liked about Sass had started to make its way into native CSS. Custom properties were one of the first things. Custom properties are more powerful than Sass variables because you can assign a new value to a custom property in a media query or in a theming system, like light and dark modes. That’s something Sass is unable to do since variables become static once they are compiled into vanilla CSS. You can also assign and update custom properties with JavaScript. Custom properties also work with inheritance and have a broader scope than Sass variables.

So, yeah. I found that not only was I already fairly familiar with the concept of variables, thanks to Sass, but the native CSS version was much more powerful.

I first used CSS Custom Properties when building two different themes (light and dark) for a client project. I also used them several times with JavaScript and liked how it gave me new possibilities for using CSS and JavaScript together. In my new job, we use custom properties extensively and I have completely switched over to using them in any new code that I write. I made use of custom properties extensively when I redesigned my personal site last year. I took advantage of it to create a light and dark theme and I utilized it with Utopia for typography and spacing utilities.

Nesting

When Sass introduced nesting, it simplified the writing of CSS code because you write style rules within another style rule (usually a parent). This means that you no longer had to write out the full descendent selector as a separate rule. You could also nest media queries, feature queries, and container queries.

This ability to group code together made it easier to see the relationships between parent and child selectors. It was also useful to have the media queries, container queries, or feature queries grouped inside those selectors rather than grouping all the media query rules together further down in the stylesheet.

I already mentioned that I stumbled across native CSS nesting when writing code for my memory keeper prototype. I was very excited that the specification extended what I already knew about nesting from Sass.

Two years ago, the nesting specification was going to require you to start the nested query with the & symbol, which was different from how it worked in Sass.

.footer { a { color: blue } } /* 2023 */ .footer { & a { color: blue } /* This was valid then */ }

But that changed sometime in the last two years and you no longer need the ampersand (&) symbol to write a nested query. You can write just as you had been writing it in Sass. I am very happy about this change because it means native CSS nesting is just like I have been writing it in Sass.

/* 2025 */ .footer { a { color: blue } /* Today's valid syntax */ }

There are some differences in the native implementation of nesting versus Sass. One difference is that you cannot create concatenated selectors with CSS. If you love BEM, then you probably made use of this feature in Sass. But it does not work in native CSS.

.card { &__title {} &__body {} &__footer {} }

It does not work because the & symbol is a live object in native CSS and it is always treated as a separate selector. Don’t worry, if you don’t understand that, neither do I. The important thing is to understand the implication – you cannot concatenate selectors in native CSS nesting.

If you are interested in reading a bit more about this, I would suggest Kevin Powell’s, “Native CSS Nesting vs. Sass Nesting” from 2023. Just know that the information about having to use the & symbol before an element selector in native CSS nesting is out of date.

I never took advantage of concatenated selectors in my Sass code so this will not have an impact on my work. For me, nesting is native CSS is equivalent to how I was using it in Sass and is one of the reasons why to consider un-Sassing.

My advice is to be careful with nesting. I would suggest trying to keep your nested code to three levels at the most. Otherwise, you end up with very long selectors that may be more difficult to override in other places in our codebase. Keep it simple.

The color-mix() function

I liked using the Sass color module to lighten or darken a color. I would use this most often with buttons where I wanted the hover color to be different. It was really easy to do with Sass. (I am using $color to stand in for the color value).

background-color: darken($color, 20%);

The color-mix() function in native CSS allows me to do the same thing and I have used it extensively in the past few months since learning about it from Chris Ferdinandi.

background-color: color-mix(in oklab, var(--color), #000000 20%); Mixins and functions

I know that a lot of developers who use Sass make extensive use of mixins. In the past, I used a fair number of mixins. But a lot of the time, I was just pasting mixins from previous projects. And many times, I didn’t make as much use of them as I could because I would just plain forget that I had them. They were always nice helper functions and allowed me to not have to remember things like clearfix or font smoothing. But those were also techniques that I found myself using less and less.

I also utilized functions in Sass and created several of my own, mostly to do some math on the fly. I mainly used them to convert pixels into ems because I liked being able to define my typography and spacing as relative and creating relationships in my code. I also had written a function to covert pixels to ems for custom media queries that did not fit within the breakpoints I normally used. I had learned that it was a much better practice to use ems in media queries so that layouts would not break when a user used page zoom.

Currently, we do not have a way to do mixins and functions in native CSS. But there is work being done to add that functionality. Geoff wrote about the CSS Functions and Mixins Module.

I did a little experiment for the use case I was using Sass functions for. I wanted to calculate em units from pixels in a custom media query. My standard practice is to set the body text size to 100% which equals 16 pixels by default. So, I wrote a calc() function to see if I could replicate what my Sass function provided me.

@media (min-width: calc((600 / 16) * 1em));

This custom media query is for a minimum width of 600px. This would work based on my setting the base font size to 100%. It could be modified.

Tired of tooling

Another reason to consider un-Sassing is that I am simply tired of tooling. Tooling has gotten more and more complex over the years, and not necessarily with a better developer experience. From what I have observed, today’s tooling is predominantly geared towards JavaScript-first developers, or anyone using a framework like React. All I need is a tool that is easy to set up and maintain. I don’t want to have to learn a complex system in order to do very simple tasks.

Another issue is dependencies. At my current job, I needed to add some new content and styles to an older WordPress site that had not been updated in several years. The site used Sass, and after a bit of digging, I discovered that the previous developer had used CodeKit to process the code. I renewed my Codekit license so that I could add CSS to style the content I was adding. It took me a bit to get the settings correct because the settings in the repo were not saving the processed files to the correct location.

Once I finally got that set, I continued to encounter errors. Dart Sass, the engine that powers Sass, introduced changes to the syntax that broke the existing code. I started refactoring a large amount of code to update the site to the correct syntax, allowing me to write new code that would be processed. 

I spent about 10 minutes attempting to refactor the older code, but was still getting errors. I just needed to add a few lines of CSS to style the new content I was adding to the site. So, I decided to go rogue and write the new CSS I needed directly in the WordPress template. I have had similar experiences with other legacy codebases, and that’s the sort of thing that can happen when you’re super reliant on third-party dependencies. You spend more time trying to refactor the Sass code so you can get to the point where you can add new code and have it compiled.

All of this has left me tired of tooling. I am fortune enough at my new position that the tooling is all set up through the Django CMS. But even with that system, I have run into issues. For example, I tried using a mixture of percentage and pixels values in a minmax() function and Sass was trying to evaluate it as a math function and the units were incompatible.

grid-template-columns: repeat(auto-fill, minmax(min(200px, 100%), 1fr));

I needed to be able to escape and not have Sass try to evaluate the code as a math function:

grid-template-columns: repeat(auto-fill, minmax(unquote("min(200px, 100%)"), 1fr));

This is not a huge pain point but it was something that I had to take some time to investigate that I could have been using to write HTML or CSS. Thankfully, that is something Ana Tudor has written about.

All of these different pain points lead me to be tired of having to mess with tooling. It is another reason why I have considered un-Sassing.

Verdict

So what is my verdict — is it time to un-Sass?

Please don’t hate me, but my conclusion is: it depends. Maybe not the definitive answer you were looking for.

But you probably are not surprised. If you have been working in web development even a short amount of time, you know that there are very few definitive ways of doing things. There are a lot of different approaches, and just because someone else solves it differently, does not mean you are right and they are wrong (or vice versa). Most things come down to the project you are working on, your audience, and a host of other factors.

For my personal site, yes, I would like to un-Sass. I want to kick the build process to the curb and eliminate those dependencies. I would also like for other developers to be able to view source on my CSS. You can’t view source on Sass. And part of the reason I write on my site is to share solutions that might benefit others, and making code more accessible is a nice maintenance enhancement.

My personal site does not have a very large codebase. I could probably easily un-Sass it in a couple of days or over a weekend.

But for larger sites, like the codebase I work with at my job. I wouldn’t suggest un-Sassing it. There is way too much code that would have to be refactored and I am unable to justify the cost for that kind of effort. And honestly, it is not something I feel motivated to tackle. It works just fine the way that it is. And Sass is still a very good tool to use. It’s not “breaking” anything.

Your project may be different and there might be more gains from un-Sassing than the project I work on. Again, it depends.

The way forward

It is an exciting time to be a CSS developer. The language is continuing to evolve and mature. And every day, it is incorporating new features that first came to us through other third-party tools such as Sass. It is always a good idea to stop and re-evaluate your technology decisions to determine if they still hold up or if more modern approaches would be a better way forward.

That does not mean we have to go back and “fix” all of our old projects. And it might not mean doing a complete overhaul. A lot of newer techniques can live side by side with the older ones. We have a mix of both Sass variables and CSS custom properties in our codebase. They don’t work against each other. The great thing about web technologies is that they build on each other and there is usually backward compatibility.

Don’t be afraid to try new things. And don’t judge your past work based on what you know today. You did the best you could given your skill level, the constraints of the project, and the technologies you had available. You can start to incorporate newer ways right alongside the old ones. Just build websites!

Is it Time to Un-Sass? originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

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.

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.

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.

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 Oct 7, 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.

Steven Heller’s Font of the Month: Ritualist

Typography - Mon, 09/01/2025 - 8:58pm

Read the book, Typographic Firsts

Without screwing up the space/time continuum, if I could go back in time, let’s say 45 years ago when I edited, art directed and designed underground tabloid magazine-newspapers, and I could take back a contemporary font family to use as the exclusive typeface for one of those periodicals, I would choose Ritualist designed by Justin […]

The post Steven Heller’s Font of the Month: Ritualist appeared first on I Love Typography.

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.

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.

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.

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.

Syndicate content
©2003 - Present Akamai Design & Development.