The code for everything in this post is on my GitHub repo.
We’ll look at a toy task management app that does minimal work so we can focus on state management. It shows a (static) list of tasks, a button to add a new task, a heading showing the number of tasks, and a component to change the UI view between three options.
Moreover, the same app was written 3 times, once using vanilla React context for state, once using Zustand simply but non-idiomatically, and then a third version using Zustand more properly, so we can see some of the performance benefits it offers.
Each of the three apps is identical, except for the label above the Add New Task button.
Each app is broken down more or less identically as so.
function App() {
console.log("Rendering App");
return (
<div className="m-5 p-5 flex flex-col gap-2">
<VanillaLabel />
<AddNewTask />
<TasksCount />
<TasksHeader />
<Filter />
<TasksBody />
</div>
);
}
It’s probably more components than needed, but it’ll help us inspect render performance.
Our state payload for this app will include an array of tasks, a method to update the tasks, the current UI view being displayed, a function to update it, and a current filter, with, of course, a method to update it.
Those values can all be declared as various pieces of state, and then passed down the component tree as needed. This is simple and it works, but the excessive amount of prop passing, often referred to as “prop drilling,” can get annoying pretty quickly. There are many ways to avoid this, from state management libraries like Zustand, Redux, and MobX, to the regular old React context.
In this post, we’ll first explore what this looks like using React context, and then we’ll examine how Zustand can simplify things while improving performance in the process.
There’s a very good argument to be made that React’s context feature was not designed to be a state management library, but that hasn’t stopped many devs from trying. To avoid excessive prop drilling while minimizing external dependencies, developers will often store the state required for a specific part of their UI in context and access it lower in the component tree as needed.
Our app has its entire state stored like this, but that’s just a product of how unrealistically small it is.
Let’s get started. First, we have to declare our context
const TasksContext = createContext<TasksState>(null as any);
Then we need a component that renders a Provider for that context, while declaring, and then passing in the actual state
export const TasksProvider = ({ children }: { children: ReactNode }) => {
console.log("Rendering TasksProvider");
const [tasks, setTasks] = useState<Task[]>(dummyTasks);
const [currentView, setCurrentView] = useState<TasksView>("list");
const [currentFilter, setCurrentFilter] = useState<string>("");
const value: TasksState = {
tasks,
setTasks,
currentView,
setCurrentView,
currentFilter,
setCurrentFilter,
};
return <TasksContext.Provider value={value}>{children}</TasksContext.Provider>;
};
The logging console.log("Rendering TasksProvider");
is present in every component in all versions of this app, so we can inspect re-renders.
Notice how we have to declare each piece of state with useState
(or useReducer
)
const [tasks, setTasks] = useState<Task[]>(dummyTasks);
const [currentView, setCurrentView] = useState<TasksView>("list");
const [currentFilter, setCurrentFilter] = useState<string>("");
and then splice it together in our big state payload, and then render our context provider
const value: TasksState = {
tasks,
setTasks,
currentView,
setCurrentView,
currentFilter,
setCurrentFilter,
};
return <TasksContext.Provider value={value}>{children}</TasksContext.Provider>;
To get the current context value in a component that wants to use it, we call the useContext
hook, and pass in the context object we declared above. To simplify this, it’s not uncommon to build a simple hook for just this purpose.
export const useTasksContext = () => {
return useContext(TasksContext);
};
Now components can grab whatever slice of state they need.
const { currentView, tasks, currentFilter } = useTasksContext();
This code is fine. It’s simple enough. And it works. I’ll be honest, though, as someone who works with code like this a lot, the boilerplate can become annoying pretty quickly. We have to declare each piece of state with the normal React primitives (useState, useReducer), and then also integrate it into our context payload (and typings). It’s not the worst thing to deal with; it’s just annoying.
Another downside of this code is that all consumers of this context will always rerender anytime any part of the context changes, even if that particular component is not using the part of the context that just changed. We can see that with the logging that’s in these components.
For example, changing the current UI view rerenders everything, even though only the task header, and task body read that state
Zustand is a minimal but powerful state management library. To create state, Zustand gives you a create
method
import { create } from "zustand";
It’s easier to show this than to describe it.
export const useTasksStore = create<TasksState>(set => ({
tasks,
setTasks: (arg: Task[] | ((tasks: Task[]) => Task[])) => {
set(state => {
return {
tasks: typeof arg === "function" ? arg(state.tasks) : arg,
};
});
},
currentView: "list",
setCurrentView: (newView: TasksView) => set({ currentView: newView }),
currentFilter: "",
setCurrentFilter: (newFilter: string) => set({ currentFilter: newFilter }),
}));
We pass a function to create
and return our state. Just like that. Simple and humble. The function we pass also takes an argument, which I’ve called set
. The result of the create
function, which I’ve named useTasksStore
here, will be a React hook that you use to read your state.
Updating our state couldn’t be simpler. The set
function we see above is how we do that. Notice our updating functions like this:
setCurrentView: (newView: TasksView) => set({ currentView: newView }),
By default set
will take what we return, and integrate it into the state that’s already there. So we can return the pieces that have changed, and Zustand will handle the update.
Naturally, there’s an override: if we pass true
for the second argument to set
, then what we return will overwrite the existing state in its entirety.
clear: () => set({}, true);
The above would wipe our state, and replace it with an empty object; use this cautiously!
To read our state in the components which need it, we call the hook that was returned from create
, which would be useTasksStore
from above. We could read our state in the same way we read our context above
This is not the best way to use Zustand. Keep reading for a better way to use this API.
const { currentView, tasks, currentFilter } = useTasksStore();
This will work and behave exactly like our context example before.
This means changing the current UI view will again re-render all components that read anything from the Zustand store, whether related to this piece of state, or not.
It’s easy to miss in the docs the first time you read them, but when reading from your Zustand store, you shouldn’t do this:
const { yourFields } = useTasksStore();
Zustand is well optimized, and will cause the component with the call to useTasksStore
to only re-render when the result of the hook call changes. By default, it returns an object with your entire state. And when you change any piece of your state, the surrounding object will have to be recreated by Zustand, and will no longer match.
Instead, you should pass a selector argument into useTasksStore
, in order to select the piece of state you want. The simplest usage would look like this
const currentView = useTasksStore(state => state.currentView);
const tasks = useTasksStore(state => state.tasks);
const currentFilter = useTasksStore(state => state.currentFilter);
Now our call returns only the currentView
value in the first line, or our tasks
array, or currentFilter
in our second and third lines, respectively.
The value returned for currentView
will only be different if you’ve changed that state value, and so on with tasks
, and currentFilter
. That means if none of these values have changed, then this component will not rerender, even if other values in our Zustand store have changed.
If you don’t like having those multiple calls, you’re free to use Zustand’s useShallow
helper
import { useShallow } from "zustand/react/shallow";
// ...
const { tasks, setTasks } = useTasksStore(
useShallow(state => ({
tasks: state.tasks,
setTasks: state.setTasks,
}))
);
The useShallow
hook lets us return an object with the state we want, and will trigger a rerender only if a shallow check on the properties in this object change.
If you want to save a few lines of code, you’re also free to return an array with useShallow
.
const [tasks, setTasks] = useTasksStore(useShallow(state => [state.tasks, state.setTasks]));
This does the same thing.
The Zustand-optimized version of the app only uses the useTasksStore
hook with a selector function, which means we can observe our improved re-rendering.
Changing the current UI view will only rerender the components that use the ui view part of the state.
For a trivial app like this, it probably won’t matter, but for a large app at scale, this can be beneficial, especially for users on slower devices.
The full Zustand docs are here. Zustand has a delightfully small surface area, so I’d urge you to just read the docs if you’re curious.
That being said, there are a few features worth noting here.
Zustand doesn’t care where or when the set
function is called. You’re free to have async methods in your store, which call set
after a fetch.
The docs offer this example:
const useFishStore = create(set => ({
fishies: {},
fetch: async pond => {
const response = await fetch(pond);
set({ fishies: await response.json() });
},
}));
set
We already know that we can call set(oldState => newState)
, but what if we need (or just want) to read the current state inside one of our actions, unrelated to an update?
It turns out create
also has a second argument, get
, that you can use for this very purpose
export const useTasksStore = create<TasksState>((set, get) => ({
And now you can do something like this
logOddTasks: () => {
const oddTasks = get().tasks.filter((_, index) => index % 2 === 0);
console.log({ oddTasks: oddTasks });
},
The first line grabs a piece of state, completely detached from any updates.
Zustand gives you back a React hook from create
. But what if you want to read your state outside of a React component? Zustand attaches a getState()
method directly onto your hook, which you can call anywhere.
useEffect(() => {
setTimeout(() => {
console.log("Can't call a hook here");
const tasks = useTasksStore.getState().tasks;
console.log({ tasks });
}, 1000);
}, []);
Zustand also supports manual, fine-grained subscriptions; bindings for vanilla JavaScript, with no React at all; and integrates well with immutable helpers like Immer. It also has some other, more advanced goodies that we won’t try to cover here. Check out the docs if this post has sparked your interest!
Zustand is a wonderfully simple, frankly fun library to use to manage state management in React. And as an added bonus, it can also improve your render performance.
]]>Here’s an example: a user scrolls through a Terms & Conditions page. If they click “agree” without having scrolled down until the end, we could prompt them with a “please confirm you’ve read these terms” before continuing. Whereas if they have scrolled down the whole way, that could imply they have read the terms, so we don’t need the additional prompt.
Implementing something like this is relatively easy with the recent CSS scroll-state queries (browser support).
The following is an example of exactly as described above. If you click the “Sign Up” button without having scrolled down until the end, you’ll see an additional prompt reminding that you might not have read the terms yet and if you’d still like to sign up. And if the “Sign Up” is clicked after the text has been scrolled to the end, the sign-up acknowledgement pops up without any confirmation prompt first.
Here’s a live demo:
We’ll start with this basic layout:
<article>
<!-- many paragraphs of ToS text goes here -->
<div class="control">
<button>Sign Up</button>
</div>
</article>
article {
overflow: scroll;
container-type: scroll-state;
.control {
position: sticky;
bottom: -20px;
}
}
The sign up button’s container (.control
) is a sticky element that sticks to the bottom of its scrollable container (<article>
). This is so the user always has access to the sign up button, in case they prefer to drop reading the terms and sign up right away.
The scrollable container (<article>
) has container-type: scroll-state.
This makes it possible to make changes to its descendants based on their scroll positions.
This is where we code in how the button control’s action adapts to its scroll position inside the article
.
@container not scroll-state(scrollable: bottom) {
button {
appearance: button;
}
}
When the container (<article>
in our example) can no longer be scrolled further down, i.e. the container has already been scrolled until its bottom edge, we make a subtle change to the button in CSS that won’t visually modify it. In the example above, the button’s appearance is set to button from its default auto, keeping the button’s look the same.
When the button is clicked, depending on the value of its appearance
property, show the relevant alert.
document.querySelector('button').onclick = (e) => {
if (getComputedStyle(e.target).appearance == "auto"
&& !confirm("Hope you've read the terms. Do you wish to complete the sign up?"))
return;
alert("Sign up complete");
};
If the <article>
has not been scrolled down until the end, the button’s appearance
value remains its default auto (getComputedStyle(e.target).appearance == "auto"
). The click handler executes a confirm()
prompt reminding the user they might not have read the terms fully yet, and if they’d like to continue with the sign up. If the user clicks “OK”, the alert("Sign up complete")
message shows up next.
If the article has been scrolled down to the end, the button will have an appearance
value other than auto
, and so the click handler executes the alert()
only.
Learn about scroll-state queries (here and/or here) to know the different kinds of scrolling scenarios that you can work with. Based on scroll states and positions, you’ll be able to change the appearance, content, or even functionality (as seen in this article) of an element or module.
]]>So I’m at CSS Day in Amsterdam this past month, and there was a lovely side event called CSS Café. I’m 90% sure it was during a talk by Johannes Odland and a coworker of his at NRK (whose name I embarrassingly cannot remember) where they showed off something like an illustration of a buoy floating in the water with waves in front of it. Somehow, someway, the CSS property animation-composition was involved, and I was like what the heck is that? I took notes during the presentation, and my notes simply said “animation-composition”, which wasn’t exactly helpful.
I nearly forgot about it when I read Josh Comeau’s blog post Partial Keyframes, where he talks about “dynamic, composable CSS keyframes”, which, as I recall was similar to what Johannes was talking about. There is some interesting stuff in Josh’s post — I liked the stuff about comma-separating multiple animations — but alas, nothing about animation-composition
.
So I figured I’d stream about it, and so I did that, where I literally read the animation-composition
docs on MDN and played with things. I found their basic/weird demo intriguing and learned from that. Say you’ve got a thing and it’s got some transfoms already on it:
.thing {
transform: translateX(50px) rotate(20deg);
}
Then you put a @keyframes
animation on it also:
.thing {
transform: translateX(50px) rotate(20deg);
animation: doAnimation 5s infinite alternate;
}
@keyframes doAnimation {
from {
transform: translateX(0)
}
to {
transform: translateX(100px)
}
}
Pop quiz: what is the translateX()
value going to be at the beginning of that animation?
It’s not a trick question. If you intuition tells you that it’s going to be translateX(0)
, you’re right. The “new” transform
in the @keyframes
is going to “wipe out” any existing transform
on that element and replace it with what is described in the @keyframes
animation.
That’s because the default behavior is animation-composition: replace;
. It’s a perfectly fine default and likely what you’re used to doing.
But there are other possible values for animation-composition
that behave differently, and we’ll look at those in a second. But first, the fact that transform
can take a “space-separated” list of values is already kind of interesting. When you do transform: translateX(50px) rotate(20deg);
, both of those values are going to apply. That’s also relatively intuitive once you know it’s possible.
What is less intuitive but very interesting is that you can keep going with more space-separated values, even repeating ones that are already there. And there I definitely learned something! Say we tack on another translateX()
value onto it:
.thing {
transform: translateX(50px) rotate(20deg) translateX(50px);
}
My brain goes: oh, it’s probably basically the same as translateX(100px) rotate(20deg);
. But that’s not true. The transforms apply one at a time, and in order. So what actually happens is:
I’m starting to get this in my head, so I streamed again the next day and put it to work.
What popped into my head was a computer language called Logo that I played with as a kid in elementary school. Just look at the main image from the Wikipedia page. And the homepage of the manual is very nostoligic for me.
We can totally make a “turtle” move like that.
All I did here is put a couple of buttons on the page that append more transform
values to this turtle element. And sure enough, it moves around just like the turtle of my childhood.
But Mr. Turtle there doesn’t really have anything to do with animation-composition
, which was the origin of this whole story. But it’s sets up understanding what happens with animation-composition
. Remember this setup?
.thing {
transform: translateX(50px) rotate(20deg);
animation: doAnimation 5s infinite alternate;
}
@keyframes doAnimation {
from {
transform: translateX(0)
}
to {
transform: translateX(100px)
}
}
The big question is: what happens to the transform
that is already on the element when the @keyframes
run?
If we add animation-composition: add;
it adds what is going on in the @keyframes
to what is already there, by appending to the end of the list, as it were.
.thing {
transform: translateX(50px) rotate(20deg);
animation: doAnimation 5s infinite alternate;
animation-composition: add;
}
@keyframes doAnimation {
from {
transform: translateX(0);
/* starts as if:
transform: translateX(50px) rotate(20deg) translateX(0); */
}
to {
transform: translateX(100px);
/* ends as if:
transform: translateX(50px) rotate(20deg) translateX(100px); */
}
}
If we did animation-composition: accumulate;
it’s slightly different behavior. Rather than appending to the list of space-separated values, it increments the values if it finds a match.
.thing {
transform: translateX(50px) rotate(20deg);
animation: doAnimation 5s infinite alternate;
animation-composition: accumulate;
}
@keyframes doAnimation {
from {
transform: translateX(0);
/* starts as if:
transform: translateX(50px) rotate(20deg); */
}
to {
transform: translateX(100px);
/* ends as if:
transform: translateX(150px) rotate(20deg) */
}
}
It’s not just transform
that behave this way, I just found it a useful way to grok it. (Which is also why I had space-separated filter
on the mind.) For instance, if a @keyframes
was adjusting opacity and we used add
or accumulate
, it would only ever increase an opacity value.
.thing {
opacity: .5;
transform: translateX(50px) rotate(20deg);
animation: doAnimation 2s infinite alternate;
animation-composition: add;
}
@keyframes doAnimation {
from {
opacity: 0;
/* thing would never actually be 0 opacity, it would start at 0.5 and go up */
}
to {
opacity: 1;
}
}
So that’s that! Understanding how “stacked” transforms works is very interesting to me and I have a feeling will come in useful someday. And I feel the same way about animation-composition
. You won’t need it until you need it.
const fruits = ["apple", "banana", "cherry"];
/* newer, easier */
console.log(fruits.at(-1));
/* older, harder */
console.log(fruits[fruits.length - 1]);
]]>Now we’re in Part 3, and we’ll pick up where we left off once again, and do two more very practical things:
Once we’re done, we’ll be doing what dare-I-say most websites are doing. While we’re keeping this very beginner-focused, here’s the general outline for how websites operate: use tools to create files that become websites, place those files in version control, and utilize services to host our site.
Why slap a build process onto a simple site?
It’s true: you don’t always need a build process, and I think avoiding adding tools is a better lesson than adding them because you think you have to. So I want to be clear here that we’re doing it here because we’re learning. But here are some reasons why we might:
We’ll be adding Astro as the site-building tool for us, so it’s the tool that will be running the build process.
The trick here is to scaffold a new, bare-bones Astro site, then move the HTML/CSS/JavaScript assets from our existing project into place in the Astro project.
Astro is clear that the right way to get started is using the command line. So far in this series, we’ve avoided the command line, using the GitHub Desktop app to do Git work, where git is natively a command line tool. But we’ve got no choice here, so let’s do it. I hope this isn’t a showstopper for anyone, but I think it’s outside the scope of this series to explain the command line. Naturally, there is a great course right here: Complete Intro to Linux and the Command-Line. The good news is that any operating system will have a free and perfectly serviceable command line app for you to use (like Terminal.app on macOS), and we won’t need it for long. We just need to get it open, then copy/paste the command Astro says to run on their homepage:
It’ll ask you a series of questions, where the default answer is likely fine, and you’ll end up with all the necessary files to run a basic Astro site.
Slightly tricky part here for an absolute beginner: It’s going to make the Astro site into a folder, and that folder might have a strange, random name (if you didn’t name it yourself during the scaffolding questions). So in the terminal, you’ll type cd [name-of-folder]
to “move” into it (“cd” is “change directory”). From inside that folder, now you can type npm run dev
and it will run the Astro site. This is a change from our previous entirely static site. Now, when we’re working on our Astro site, we need to run it like we just did.
Now this looks absolutely nothing like our site, which is expected. We now need to move our HTML/CSS/JavaScript into the Astro-scaffolded files. These will be jobs like:
.astro
files to take best advantage of Astro features. So it’s likey our simple setup will involve porting most of it to an src/pages/index.astro
file.src/assets
and linking them up how Astro likes to do it.It might be useful to look at my Git Commit that does this conversion.
It’s essentially our job to review the Astro site’s structure and integrate our existing code into the Astro setup.
Our plan was to build out some of our content using Markdown files. We’re jamming in this concept because we’re learning and it’s interesting. But it’s also a fairly common need and project requirement. So let’s do it with the “work” section of our portfolio site.
We’ve got these six images here. Let’s flesh out that section and make it actually powered by six Markdown files that link up the image but also have actual information about the project we worked on. This is a portfolio after all!
This is a perfect situation for Astro’s content collections. We can define what we want a work item to be like from a data perspective, then make Markdown files for each item.
You can absolutely do all this work by hand. I might argue that you should. More than once. But I also don’t want to be ignorant to the AI revolution in how developers are working these days. I also think that fairly rote tasks like this are done usually quite well by AI agents. That’s particularly true here, as we’re doing something very basic and with-the-grain in a fairly popular framework with good open documentation.
I used an AI agent myself to do this job because I wanted to give it a whirl! I had just heard of Jules from Google so I gave that one a try, but there are so many other choices. I’ve used Cursor a bunch which just launched a web version of agents which seems interesting, for example.
I told Jules:
in index.astro, there is a div with class “work__container”. I want to turn that area into an Astro Collection. Each of those images should actually be a markdown file. That markdown file has more stuff in it like a title and description as well as the image.
I’m sure it would have been happy to take follow-up instructions and all that, but this single prompt did the job just fine, and it ended up as a PR (Pull Request) against the GitHub repo we set up.
Just a little hand-tweaking of that new Work.astro
file, and we have a nice new section that will be easy to update in the future by simple editing Markdown files.
We need to tell Netlify that our site is different now! No longer is it entirely static files. It’s true that Astro makes totally static files that can essentially be served in the same way, but when you use a site-building tool like Astro, the approach is to have the build process run when you deploy the site. That might sound a little strange if you’re learning about this for the first time, but it’s true.
When Astro builds your site for you locally, it builds your website in a folder called dist
. You can see that in the .gitignore
file that came into existence when we scaffolded Astro, dist is in there, which means “do not track any of the files in that folder in Git”, meaning they don’t go to GitHub at all, and don’t go to Netlify. The reason for that is generally that it’s just noisy. The changes to those “built” files will occur on almost every commit, and it’s not particularly interesting to see those files change in Git. It’s interesting to see what you, the author, changed, not the changes to the built files. So, because Netlify doesn’t have them, it can just build them for itself.
We need to go into the Netlify settings for our project into Build & deploy > Continuous deployment > Build settings.
We update our “build command” to npm run build
and the “publish directory” to dist
.
Netlify is smart enough to do this itself when you add an Astro project from the get-go, but here we’re changing the site from totally static to Astro, so it’s our job to update it.
A new deployment from Netlify (which you can do from a new commit to GitHub or Deploys > Trigger Deploy in the Netlify dashboard) and we’re in business:
Right now I’ve got mycoolpersonalportfolio.netlify.app
which is indeed a “real” domain name. But it’s just the free subdomain that Netlify gives you. It’s neat you can customize it, but it doesn’t have quite the professional feel that your own domain name would have. For example, my real website is at chriscoyier.net
and that feels much better to me.
A domain like that is something you own. You have to buy it, and you can have it forever as long as you pay the renewal costs. In a sense, the domain name is almost more important than the website that’s on it since the content can and will change but the domain name won’t.
Netlify itself will help you buy a domain name. And honestly, it’s almost surely the easiest path forward here to do that, as they are incentivized to make it easy and work flawlessly. That’s fine, it’ll get the job done.
But personally, I like to keep the domains I own registered separately from the web host. Let’s say you want to leave Netlify hosting one day, wouldn’t that be weird to manage the domain at Netlify while dealing with the hosting somewhere else? It feels weird to me, like the incentives are now off.
I have most of my domains on GoDaddy, which is a big, popular choice, but I’ve heard good things about Porkbun, there is Cloudflare, and a million others.
I own coyier.dev
and I’ve never done anything with it, so what I’ll do is set it up as the domain for this project.
The trick is updating the DNS information for the domain name I own to what Netlify wants to host the site properly. In Netlify, I go to Domain Management and get to this area. At the same time, I’m logged into GoDaddy and find my way to the DNS nameservers area. I need to update the nameservers in GoDaddy to the ones Netlify tells me to use.
It’s likely to take a couple of hours for you to see this actually work. DNS is strange and mysterious though, requiring routers around the world to learn this new information, so it’s possible it takes 24 hours or more.
Once this process is done. The DNS is resolving, as they say, we’re done here!
We’ve done what we set out to do. We have a portfolio website for ourselves.
The code for it we got on GitHub, which is a great place for it. Just think, if we drop our computer into a lake, when we get a new computer, we can just pull the code down again from GitHub and away we go. We could even invite a friend to help us with it and our changes will merge together.
We’re using Astro to build the site, which does all sorts of useful things for us like making the best static website it can make. We’re taking advantage of it’s build process to manage the work area of the site so it’ll be easy to add and change things.
We have Netlify hosting the site for us, which makes the website a real website that anyone in the world can visit. Netlify even builds our website for us when we commit new code to GitHub. This keeps our GitHub repo nice and clean.
We have a real domain name that we’ve “pointed” at Netlify. This gives us a nice level of professionalism and control.
If you’ve used this as a guide to do this work, I’d love to hear from you. You’re well on your web to becoming a web professional. If you’re anything like me, you get a nice sense of satisfaction from this whole process. 💜
]]>async
and await
, but it’s just what they call “syntactic sugar” over Promises, which is to say, a cleaner way to write the same code.
Will Sentance goes deep into Promises in JavaScript: The Hard Parts when you’re ready.
]]>The example that got stuck in my head was reordering lists. Imagine a single list item being plucked off and moved to the top. If that instantly happens, it can be hard to catch what even happened. But if you animate the movement, it can be extremely obvious what is happening.
Works, but it is not particularly easy to understand what is happening:
More fun and easier to understand what is happening:
We’re talking a regular ol list. Perhaps ironic that we’re ordering and unordered lists, but I’ll leave that as a semantic thoughtworm for the reader.
Each list item has text, then a button which the intended action is that, when clicked, will move the list item to the top.
<ul class="list">
<li>
Apples
<button hidden disabled aria-label="Move to Top">
<svg ...></svg>
</button>
</li>
<li>
Oranges
<button hidden disabled aria-label="Move to Top">
<svg ...></svg>
</button>
</li>
<li>
<button hidden disabled aria-label="Move to Top">
<svg ...></svg>
</button>
Mangos
</li>
<li>
<button hidden disabled aria-label="Move to Top">
<svg ...></svg>
</button>
Bananas
</li>
</ul>
Note that each button has a text label (as we’re not using text inside the button), and a hidden
attribute we’ll use to make sure the button isn’t there at all when JavaScript is disabled.
This will get us references to the elements we need, as well as do a loop and un-hide the buttons as well as attach an event listener to them:
const button = document.querySelector("button");
const list = document.querySelector(".list");
let listItems = list.querySelectorAll("li");
const listItemButtons = list.querySelectorAll("li > button");
listItemButtons.forEach((button) => {
button.hidden = false;
button.addEventListener("click", async () => {
// do stuff
});
});
When the button is clicked, we’ll need the list item, not the button itself, so we reach up a level to the parent. Then we freshly figure out what the first list item is, and insertBefore
it, making the clicked one the first one.
const button = document.querySelector("button");
const list = document.querySelector(".list");
let listItems = list.querySelectorAll("li");
const listItemButtons = list.querySelectorAll("li > button");
listItemButtons.forEach((button) => {
button.hidden = false;
button.addEventListener("click", async () => {
const item = button.parentElement;
const firstListItem = list.querySelector(".list :first-child");
list.insertBefore(item, firstListItem);
// This is probably the better API to use, but less supported...
// list.moveBefore(item, firstListItem);
});
});
I only recently learned about moveBefore
which is probably a better API to use, but we can wait a bit for better support.
One type of View Transitions are “same page” View Transitions, where we essentially call document.startViewTransition
and change the DOM inside the callback.
const button = document.querySelector("button");
const list = document.querySelector(".list");
let listItems = list.querySelectorAll("li");
const listItemButtons = list.querySelectorAll("li > button");
function moveListItemFirst(item) {
const firstListItem = list.querySelector(".list :first-child");
list.insertBefore(item, firstListItem);
}
listItemButtons.forEach((button) => {
button.hidden = false;
button.addEventListener("click", async () => {
const item = button.parentElement;
if (document.startViewTransition) {
const transition = document.startViewTransition(() => {
moveListItemFirst(item);
});
} else {
moveListItemFirst(item);
}
});
});
Because we need to move the list item whether the browser supports View Transitions or not, we abstract that to a function, and call it on either branch of logic testing that support.
This will immediately do a fade transition for the list items, which honestly isn’t much of an improvement in this case (it still can be nice for the other type of View Transitions: page transitions). Fortunately, we’ve got a pretty decent one-line fix in CSS:
ul {
li {
view-transition-name: match-element;
}
}
If you’ve played with View Transitions before, it’s likely you’ve got in your head that every single element needs a unique view-transition-name
. And that’s still true in Firefox for now, as only Chrome and Safari are supporting match-element
as I write. But as we’re just playing here, this is such a nice improvement and reduces so much fiddliness, I think it’s worth it.
The deal here is really that all the elements are moving. It’s either the element you clicked on moving to the first position, or the rest of the list items moving out of the way.
So the goal here is to apply a unique view-transition-name
to the element that is the “main moving element”, then remove it once it’s done. To make matters a bit more difficult, we’ve got two animations we want to apply, one of the list item, and one just for the icon within the button. That’s slightly tricky!
const button = document.querySelector("button");
const list = document.querySelector(".list");
let listItems = list.querySelectorAll("li");
const listItemButtons = list.querySelectorAll("li > button");
function moveListItemFirst(item) {
const firstListItem = list.querySelector(".list :first-child");
list.insertBefore(item, firstListItem);
}
listItemButtons.forEach((button) => {
button.hidden = false;
button.addEventListener("click", async () => {
const item = button.parentElement;
item.style.viewTransitionName = "woosh";
item.querySelector("svg").style.viewTransitionName = "tony-hawk";
if (document.startViewTransition) {
const transition = document.startViewTransition(() => {
moveListItemFirst(item);
});
try {
await transition.finished;
} finally {
item.style.viewTransitionName = "";
item.querySelector("svg").style.viewTransitionName = "";
makeFirstListItemsButtonDisabled();
}
} else {
moveListItemFirst(item);
}
});
});
Now we’ve got “woosh” and “tony-hawk” view transition names we can use to apply animation control in CSS.
::view-transition-group(*) {
animation-duration: 1s;
}
::view-transition-old(woosh),
::view-transition-new(woosh) {
animation: woosh 1s ease-in-out;
}
@keyframes woosh {
50% {
translate: -100px 0;
scale: 1.5;
box-shadow: 0 30px 15px lch(0% 0 0 / 50%);
}
}
::view-transition-old(tony-hawk),
::view-transition-new(tony-hawk) {
animation: tony-hawk 1s ease-in-out;
}
@keyframes tony-hawk {
/* sick kick flip */
50% {
rotate: 20deg;
scale: 2.5;
}
}
So for the “non-main” elements, they just move up and down over 1s. But for the “main” moving element, we’ve got these unique @keyframe
animations we apply while the re-ordering is happening. Note that the keyframes are only applying the 50%
keyframe, so they animate from wherever they were to wherever they are going still, just in the middle they do something special, like the sick kick flip.
I’m playing with streaming and this idea started as a loose idea for a stream, then I lightly edited it for a regular YouTube video, so maybe you’d enjoy that:
filter
, like:
.element {
filter: blur(2px) grayscale(80%) contrast(200%);
}
All three of those filters will apply. But somehow I never thought about applying the same filter more than once. That also works, and they don’t override each other, they “stack” (or whatever you want to call it).
So here’s some useless bar trivia for you. What is blurrier, filter: blur(2px)
or filter: blur(1px) blur(1px)
? Answer.
Hot off the presses! Firefox Nightly adds the new :heading pseudo! Easily style all headings, or use nth-child-like AnB syntax to select a range of headings! Needs layout.css.heading-selector.enabled
flag enabled.