624 stories
·
2 followers

CSS Elevator: A Pure CSS State Machine With Floor Navigation

1 Comment and 2 Shares

As a developer with a passion for state machines, I’ve often found myself inspired by articles like “A Complete State Machine Made with HTML Checkboxes and CSS.” The power of pure CSS-driven state machines intrigued me, and I began to wonder: could I create something simpler, more interactive, and without the use of macros? This led to a project where I built an elevator simulation in CSS, complete with direction indicators, animated transitions, counters, and even accessibility features.

In this article, I’ll walk you through how I used modern CSS features — like custom properties, counters, the :has() pseudo-class, and @property — to build a fully functional, interactive elevator that knows where it is, where it’s headed, and how long it’ll take to get there. No JavaScript required.

Defining the State with CSS Variables

The backbone of this elevator system is the use of CSS custom properties to track its state. Below, I define several @property rules to allow transitions and typed values:

@property --current-floor {
  syntax: "<integer>";
  initial-value: 1;
  inherits: true;
}

@property --previous {
  syntax: "<number>";
  initial-value: 1;
  inherits: true;
}

@property --relative-speed {
  syntax: "<number>";
  initial-value: 4;
  inherits: true;
}

@property --direction {
  syntax: "<integer>";
  initial-value: 0;
  inherits: true;
}

These variables allow me to compare the elevator’s current floor to its previous one, calculate movement speed, and drive animations and transitions accordingly.

A regular CSS custom property (--current-floor) is great for passing values around, but the browser treats everything like a string: it doesn’t know if 5 is a number, a color, or the name of your cat. And if it doesn’t know, it can’t animate it.

That’s where @property comes in. By “registering” the variable, I can tell the browser exactly what it is (<number>, <length>, etc.), give it a starting value, and let it handle the smooth in-between frames. Without it, my elevator would just snap from floor to floor,  and that’s not the ride experience I was going for.

A Simple UI: Radio Buttons for Floors

Radio buttons provide the state triggers. Each floor corresponds to a radio input, and I use :has() to detect which one is selected:

<input type="radio" id="floor1" name="floor" value="1" checked>
<input type="radio" id="floor2" name="floor" value="2">
<input type="radio" id="floor3" name="floor" value="3">
<input type="radio" id="floor4" name="floor" value="4">
.elevator-system:has(#floor1:checked) {
  --current-floor: 1;
  --previous: var(--current-floor);
}

.elevator-system:has(#floor2:checked) {
  --current-floor: 2;
  --previous: var(--current-floor);
}

This combination lets the elevator system become a state machine, where selecting a radio button triggers transitions and calculations.

Motion via Dynamic Variables

To simulate elevator movement, I use transform: translateY(...) and calculate it with the --current-floor value:

.elevator {
  transform: translateY(calc((1 - var(--current-floor)) * var(--floor-height)));
  transition: transform calc(var(--relative-speed) * 1s);
}

The travel duration is proportional to how many floors the elevator must traverse:

--abs: calc(abs(var(--current-floor) - var(--previous)));
--relative-speed: calc(1 + var(--abs));

Let’s break that down:

  • --abs gives the absolute number of floors to move.
  • --relative-speed makes the animation slower when moving across more floors.

So, if the elevator jumps from floor 1 to 4, the animation lasts longer than it does going from floor 2 to 3. All of this is derived using just math expressions in the CSS calc() function.

Determining Direction and Arrow Behavior

The elevator’s arrow points up or down based on the change in floor:

--direction: clamp(-1, calc(var(--current-floor) - var(--previous)), 1);

.arrow {
  scale: calc(var(--direction) * 2);
  opacity: abs(var(--direction));
  transition: all 0.15s ease-in-out;
}

Here’s what’s happening:

  • The clamp() function limits the result between -1 and 1.
  • 1 means upward movement, -1 is downward, and 0 means stationary.
  • This result is used to scale the arrow, flipping it and adjusting its opacity accordingly.

It’s a lightweight way to convey directional logic using math and visual cues with no scripting.

Simulating Memory with --delay

CSS doesn’t store previous state natively. I simulate this by delaying updates to the --previous property:

.elevator-system {
  transition: --previous calc(var(--delay) * 1s);
  --delay: 1;
}

While the delay runs, the --previous value lags behind the --current-floor. That lets me calculate direction and speed during the animation. Once the delay ends, --previous catches up. This delay-based memory trick allows CSS to approximate state transitions normally done with JavaScript.

Floor Counters and Unicode Styling

Displaying floor numbers elegantly became a joy thanks to CSS counters:

#floor-display:before {
  counter-reset: display var(--current-floor);
  content: counter(display, top-display);
}

I defined a custom counter style using Unicode circled numbers:

@counter-style top-display {
  system: cyclic;
  symbols: "\278A" "\2781" "\2782" "\2783";
  suffix: "";
}

The \278A to \2783 characters correspond to the ➊, ➋, ➌, ➃ symbols and give a unique, visual charm to the display. The elevator doesn’t just say “3,” but displays it with typographic flair. This approach is handy when you want to go beyond raw digits and apply symbolic or visual meaning using nothing but CSS.

Unicode characters replacing numbers 1 through 4 with circled alternatives

Accessibility with aria-live

Accessibility matters. While CSS can’t change DOM text, it can still update screenreader-visible content using ::before and counter().

<div class="sr-only" aria-live="polite" id="floor-announcer"></div>
#floor-announcer::before {
  counter-reset: floor var(--current-floor);
  content: "Now on floor " counter(floor);
}

Add a .sr-only class to visually hide it but expose it to assistive tech:

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
}

This keeps the experience inclusive and aligned with accessibility standards.

Practical Applications of These Techniques

This elevator is more than a toy. It’s a blueprint. Consider these real-world uses:

  • Interactive prototypes without JavaScript
  • Progress indicators in forms using live state
  • Game UIs with inventory or status mechanics
  • Logic puzzles or educational tools (CSS-only state tracking!)
  • Reduced JavaScript dependencies for performance or sandboxed environments

These techniques are especially useful in static apps or restricted scripting environments (e.g., emails, certain content management system widgets).

Final Thoughts

What started as a small experiment turned into a functional CSS state machine that animates, signals direction, and announces changes, completely without JavaScript. Modern CSS can do more than we often give it credit for. With :has(), @property, counters, and a bit of clever math, you can build systems that are reactive, beautiful, and even accessible.

If you try out this technique, I’d love to see your take. And if you remix the elevator (maybe add more floors or challenges?), send it my way!


CSS Elevator: A Pure CSS State Machine With Floor Navigation originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Read the whole story
GaryBIshop
1 day ago
reply
Truly incredible!
Share this story
Delete

Draft SMS and iMessage from any computer keyboard

1 Comment

If you're like me, you don't love the ergonomics of writing long text messages on your mobile phone keyboard. We own an “Arteck HB066” Bluetooth keyboard for this use-case which works great and costs $45. But I'm not interested in spending money today.

What if I could write text messages, both SMS or iMessage, using any computer keyboard?

This little tool does just that: write text messages in this browser window, and it'll generate a QR code which you can scan with your phone camera to send the message. If you are sending to multiple recipients, use a comma (,) to delimit the different recipient phone numbers. I recommend using international codes (+1 for the USA), but it appears to work at least on iOS without them.

Don't know or don't want to type in your recipient phone number directly? Add a 1 as the recipient, scan the QR code, and then fill in the recipients on your phone to use auto-complete from your contacts list.

All data stays within the browser: your data is not processed, saved, or sent to any other server. If this tool is useful: bookmark the page for later use and let me know what you think.










QR codes generated using qrcode-svg, licensed MIT and Copyright (c) 2020 datalog.

&&&&&&&&&&&&&&&&&&&&&&&&&

Thanks for keeping RSS alive! ♥

Read the whole story
GaryBIshop
1 day ago
reply
Clever!
Share this story
Delete

Charlie: A Revolutionary Induction Range with a Built-In Battery

1 Comment

In America, the phrase "Now we're cooking with gas" was slang for "Now we're really operating efficiently."

It originated as a marketing slogan in the 1930s, created by the American Gas Association to push newfangled gas-fired ranges and ovens. And back then, gas stoves were indeed a step up from its predecessor: The wood-fired stove.

Times have changed. We now know that "cooking with gas" could be likened to "playing with fire," in terms of our health. "You wouldn't stand over the tailpipe of a car breathing in the exhaust from that car. And yet nearly 50 million households stand over a gas stove, breathing the same pollutants in their homes," Rob Jackson, an environmental scientist at Stanford University and lead author on a study on pollution from gas cooking, told Food Manufacturing. Gas stoves release pollutants like nitrogen dioxide, which is linked to asthma, and benzene, which is linked to cancer.

Gas stoves can also be a hassle for those who live in cities. Imagine an entire apartment building filled with gas stoves. If a gas leak is detected, as happens a lot—U.S. fire departments are called to some 125,000 residential gas leaks each year—gas to the entire building must be shut off while the problem is addressed. No one in the building can cook until it's fixed—and that can literally take months.

Electric stoves emit no fumes and are more efficient, with up to 80% of the heat generated reaching the target pots and pans. (Gas stoves waste about 50%.) Induction stoves are better still; since they only heat the surface that contacts the pot, their efficiency is around 90%.

The main hassles of switching to an electric or induction stove are the higher cost of the units, and the added expense of retrofitting them. Electric and induction stoves require 240V outlets, higher-amperage breakers and new wiring, which means you've got to hire an electrician to get into the walls.

Now a California-based company called Copper has solved at least one of those problems. They've designed Charlie, a revolutionary induction stove that comes with a battery. Why? Because you can plug it into a regular 120V outlet, no rewiring necessary. The battery draws power at night, when electricity costs are cheaper; when it's time to cook during the day, Charlie's got all the juice it needs. It can run all four burners and the oven at the same time.

The problem Copper hasn't yet solved is the cost. Charlie runs six grand, about double what you'd pay for a high-end electric or gas stove. Hopefully those costs will come down over time.

Even with the high price tag, the company has managed to strike a deal with the NYC Housing Authority to deliver 10,000 units. Clean-energy government incentives—which will probably go away under the new administration—brought the price down to $3,200 per unit at the time the deal was struck. The Charlie units will be a boon for building managers who don't want to deal with gas hassles and don't have the budget to retrofit every apartment's kitchen with 240V.

One challenge with installation I should mention is, the battery-laden Charlie units are extremely heavy, at over 350 lbs. (Electric or gas stoves usually weigh less than half that.) For the sake of the installers, hopefully they're going into elevator buildings.




Read the whole story
GaryBIshop
2 days ago
reply
Clever use of batteries.
Share this story
Delete

Battery Repair By Reverse Engineering

1 Comment

Ryobi is not exactly the Cadillac of cordless tools, but one still has certain expectations when buying a product. For most of us “don’t randomly stop working” is on the list. Ryobi 18-volt battery packs don’t always meet that expectation, but fortunately for the rest of us [Badar Jahangir Kayani] took matters into his own hands and reverse-engendered the pack to find all the common faults– and how to fix them.

[Badar]’s work was specifically on the Ryobi PBP005 18-volt battery packs. He’s reproduced the schematic for them and given a fairly comprehensive troubleshooting guide on his blog. The most common issue (65%) with the large number of batteries he tested had nothing to do with the cells or the circuit, but was the result of some sort of firmware lock.

It isn’t totally clear what caused the firmware to lock the batteries in these cases. We agree with [Badar] that it is probably some kind of glitch in a safety routine. Regardless, if you have one of these batteries that won’t charge and exhibits the characteristic flash pattern (flashing once, then again four times when pushing the battery test button), [Badar] has the fix for you. He actually has the written up the fix for a few flash patterns, but the firmware lockout is the one that needed the most work.

[Badar] took the time to find the J-tag pins hidden on the board, and flash the firmware from the NXP micro-controller that runs the show. Having done that, some snooping and comparison between bricked and working batteries found a single byte difference at a specific hex address. Writing the byte to zero, and refreshing the firmware results in batteries as good as new. At least as good as they were before the firmware lock-down kicked in, anyway.

He also discusses how to deal with unbalanced packs, dead diodes, and more. Thanks to the magic of buying a lot of dead packs on e-Bay, [Badar] was able to tally up the various failure modes; the firmware lockout discussed above was by far the majority of them, at 63%. [Badar]’s work is both comprehensive and impressive, and his blog is worth checking out even if you don’t use the green brand’s batteries. We’ve also embedded his video below if you’d rather watch than read and/or want to help out [Badar] get pennies from YouTube monetization. We really do have to give kudos for providing such a good write up along with the video.

This isn’t the first attempt we’ve seen at tearing into Ryobi batteries. When they’re working, the cheap packs are an excellent source of power for everything from CPap machines to electric bicycles.

Thanks to [Badar] for the tip.

 

 

Read the whole story
GaryBIshop
3 days ago
reply
"reverse-engendered the pack to find all the common faults". Reverse engendered?
Share this story
Delete

Left to Right Programming

1 Comment
2025-08-17

Programs Should Be Valid as They Are Typed


I don’t like Python’s list comprehensions:

text = "apple banana cherry\ndog emu fox"
words_on_lines = [line.split() for line in text.splitlines()]

Don’t get me wrong, declarative programming is good. However, this syntax has poor ergonomics. Your editor can’t help you out as you write it. To see what I mean, lets walk through typing this code.

words_on_lines = [l

Ideally, your editor would be to autocomplete line here. Your editor can’t do this because line hasn’t been declared yet.

words_on_lines = [line.sp

Here, our editor knows we want to access some property of line, but since it doesn’t know the type of line, it can’t make any useful suggestions. Should our editor flag line as a non-existent variable? For all it knows, we might have meant to refer to some existing lime variable.

words_on_lines = [line.split() for line in

Okay, now we know that line is the variable we’re iterating over. Is split() a method that exists for line? Who knows!

words_on_lines = [line.split() for line in text.splitlines()]

Ah! now we know the type of line and can validate the call to split(). Notice that since text had already been declared, our editor is able to autocomplete splitlines().

This sucked! If we didn’t know what the split() function was called and wanted some help from our editor, we’d have to write

words_on_lines = [_ for line in text.splitlines()]

and go back to the _ to get autocomplete on line.sp


You deserve better than this.

To see what I mean, lets look at a Rust example that does it

let text = "apple banana cherry\ndog emu fox";
let words_on_lines = text.lines().map(|line| line.split_whitespace());

If you aren’t familiar with Rust syntax, |argument| result is an anonymous function equivilent to function myfunction(argument) { return result; }

Here, your program is constructed left to right. The first time you type line is the declaration of the variable. as soon as you type line. your editor is able to give you suggestions of

This is much more pleasent. Since the program is always in a somehwat valid state as you type it, your editor is able to guide you towards the Pit of Success.


There’s a principle in design called progressive disclosure. The user should only be exposed to as much complexity as is neccessary to complete a task. Additionally, complexity should naturally surface itself as it is relevant to the user. You shouldn’t have to choose a font family and size before you start typing into Word, and options to change text wrapping around images should appear when you add an image.

In C, you can’t have methods on structs. This means that any function that could be myStruct.function(args) has to be function(myStruct, args).

Suppose you have a FILE *file and you want to get it’s contents. Ideally, you’d be able to type file. and see a list of every function that is primarily concerned with files. From there you could pick read and get on with your day.

Instead, you must know that functions releated to FILE * tend to start with f, and when you type f the best your editor can do is show you all functions ever written that start with an f. From there you can eventually find fread, but you have no confidence that it was the best choice. Maybe there was a more efficient read_lines function that does exactly what you want, but you’ll never discover it by accident.

In a more ideal language, you’d see that a close method exists while you’re typing file.read. This gives you a hint that you need to close your file when you’re done with it. You naturally came accross this information right as it became relevant to you. In C, you have to know ahead of time that fclose is a function that you’ll need to call once you’re done with the file.


C is not the only language that has this problem. Python has plenty of examples too. Consider the following Python and JavaScript snippets:


text = "lorem ipsum dolor sit amet"
word_lengths = map(len, text.split())

text = "lorem ipsum dolor sit amet"
wordLengths = text.split(" ").map(word => word.length)

While Python gets some points for using a , the functions are not discoverable. Is string length len, length, size, count, num, or ? Is there even a global function for length? You won’t know until you try all of them.

In the JavaScript version, you see length as soon as you type word.l. There is less guesswork for what the function is named. The same is true for the map. When you type .map, you know that this function is going to work with the data you have. You aren’t going to get some weird error because the map function actually expected some other type, or because your language actually calls this function .


While the Python code in the previous example is still readable, it gets worse as the complexity of the logic increases. Consider the following code that was part of my 2024 Advent of Code solutions.

len(list(filter(lambda line: all([abs(x) >= 1 and abs(x) <= 3 for x in line]) and (all([x > 0 for x in line]) or all([x < 0 for x in line])), diffs)))

Yikes. You have to jump back and forth between the start and end of the line to figure out what’s going on. “Okay so we have the length of a list of some filter which takes this lambda… is it both of these conditions or just one? Wait which parenthesis does this go with…”

In JavaScript:

diffs.filter(line => 
    line.every(x => Math.abs(x) >= 1 && Math.abs(x) <= 3) &&
    (line.every(x => x > 0) || line.every(x => x < 0))
).length;

Ah, okay. We have some list of diffs, that we filter down based on two conditons, and then we return the number that pass. The logic of the program can be read from left to right!


All of these examples illustrate a common principle:

Programs should be valid as they are typed.

When you’ve typed text, the program is valid. When you’ve typed text.split(" "), the program is valid. When you’ve typed text.split(" ").map(word => word.length), the program is valid. Since the program is valid as you build it up, your editor is able to help you out. If you had a REPL, you could even see the result as you type your program out.

Make good APIs!

Adblock test (Why?)

Read the whole story
GaryBIshop
11 days ago
reply
True
Share this story
Delete

3D Layered Text: The Basics

1 Comment

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 (coming August 20)
  3. Interactivity and Dynamism (coming August 22)

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.

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.

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:

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

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.

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 (coming August 20)
  3. Interactivity and Dynamism (coming August 22)

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

Read the whole story
GaryBIshop
12 days ago
reply
Wow!
Share this story
Delete
Next Page of Stories