2025; I think every front-end developer should know how to enable
page transitions, transition a
<dialog>
, popover, and
<details>
, animate light n' dark
gradient text,
type safe their CSS system, and
add springy easing to animation.
AI is not going to give you this CSS.
This post is a theme continuation; checkout previous years 2023 and 2024 where I shared snippets for those years.
This year, the snippets are bigger, more powerful, and leverage progressive enhancement a bit more; to help us step up to the vast UI/UX requirements of 2025.
Springy easing with linear()
#
Sprinkle life into animations with natural looking spring and bounce easings
using linear()
.
Using a series of linear lines to make "curves", you can create surprisingly realistic visual physics. A small amount can go a long way to adding interest and intrigue to the user experience.
In the following video, the top animation uses ease-out
and the bottom uses
linear()
, and I think the results are quite different, the bottom being more
desirable.
Here's some typical linear()
easing CSS 😅:
.springy {
transition: transform 1s
linear(
0,
0.009,
0.035 2.1%,
0.141,
0.281 6.7%,
0.723 12.9%,
0.938 16.7%,
1.017,
1.077,
1.121,
1.149 24.3%,
1.159,
1.163,
1.161,
1.154 29.9%,
1.129 32.8%,
1.051 39.6%,
1.017 43.1%,
0.991,
0.977 51%,
0.974 53.8%,
0.975 57.1%,
0.997 69.8%,
1.003 76.9%,
1.004 83.8%,
1
);
}
Yes… that's what your formatter will do to it, as if it's helpful in some way lol.
The linear()
code above is not very human readable, but the machines love it.
No frets, there's a few generators out there:
Tip!
Expect longer durations when usinglinear()
. When things run long, it can be nice to make them seamlessly interruptible, makinglinear()
a great fit for transitions and potentially troublesome as keyframes.
You could alternatively use premade CSS variables from a library like Open Props:
@import "https://unpkg.com/open-props/easings.min.css";
.springy {
@media (prefers-reduced-motion: no-preference) {
transition: transform 1s var(--ease-spring-3);
}
}
Easy CSS to read, comes with 5 strengths for common effects:
Incrementally adopt #
This one is super easy to toss in today.
Easiest way, use the cascade (if you're into that):
@media (prefers-reduced-motion: no-preference) {
/* just repeat the shorthand with adjusted easing */
.thingy {
transition: transform 0.3s ease;
transition: transform 0.3s linear(…);
}
/* or, target a specific property */
.thingy {
animation-timing-function: var(--ease-1);
animation-timing-function: var(--ease-spring-2);
}
}
If it knows, it knows.
Or, test for it first and scalpel apply the upgrade:
.thingy {
transition: transform 0.3s ease;
@supports (transition-timing-function: linear(0, 0.1, 1)) {
transition-timing-function: var(--ease-spring-2);
}
}
Typed custom properties #
Similar to JS variables defined with var
, CSS variables defined with --
are
global, loose, dynamic and flexible. This is great.
But… there are times, like when building a system, that you want to limit what goes into variables so a system can run with a reasonable amount of reliability.
In the above video, a variable is set to an invalid color. At first, this breaks
the system. But, @property
is
added, the system remained functioning with the latest known valid color value.
Create a typed <color>
CSS variable like this:
@property --color-1 {
syntax: "<color>";
inherits: false;
initial-value: rebeccapurple;
}
In addition to type safety, @property
defined variables can also be animated
because the browser can infer the steps needed to interpolate the value change
based on the assigned type.
Before @property
, the browser couldn’t derive a type and discover
interpolation steps, it was too complicated. Now, you give the browser a hint,
and it's simple.
In 2025, y'all front-end devs should be getting familiar with defining variables
with @property
because it:
- Formalizes CSS system interfaces
- Protects CSS systems
- Enables new animation powers
- Can perform better when using
inherits: false
Resources
- https://codepen.io/argyleink/pen/vYPdBOO
- https://www.youtube.com/watch?v=tSfSY3Ni3X0&t=3409s
- https://nerdy.dev/cant-break-this-design-system
- https://nerdy.dev/type-guarded-css-systems-with-@property
- https://www.epicweb.dev/talks/lightning-in-a-bottle-with-css-custom-properties
- https://codepen.io/argyleink/pen/MWZMxNN
View transitions for page navigation #
Y'all should know how to crossfade pages when links are clicked with this tiny view transitions snippet:
@view-transition {
navigation: auto;
}
This is the easiest snippet to add to your site, with no downsides.
It signals your website would like to use page transitions when links are clicked, and the default transition is a crossfade.
If the browser doesn't support it, it continues as it always has; but if it does support it then you dab the page with some special sauce.
There's plenty more customization you can do, like full page animations. But the gist of this section is just to share that easy snippet and the way the feature can be progressively enhanced.
Incrementally adopt #
There's many more opportunities to add additional animations with the page transition.
A great place to start enhancing this page transition experience is to identify
elements commonly found across pages, and give them a name
:
.nav {
view-transition-name: --nav;
}
.sidenav {
view-transition-name: --sidenav;
}
This includes elements in the page transition.
They can even be different elements.
a, h1 {
view-transition-name: --morphy;
}
By giving an <a>
and an <h1>
the same
view-transition-name
on two different pages, the browser will move and resize the page 1 element to
the location and size of the page 2 element, making it look like a morph. You
can of course morph between the same elements also.
There is so much more. Continue giving elements names and studying the rad examples by Bramus, and you can create experiences with motion like this:
I love the DevTools for Animations, scrubbing that full page view transition is very satisfying, and excellent for really inspecting and improving the little details.
Resources
Transition animation for <dialog>
and [popover]
#
In 2025, knowing your way around a
<dialog>
and a
[popover]
are table stakes. Otherwise, everyone else will be on top of you, and your wack
z-index
attempts will be defeated with a puny value of 1
.
These are common UI elements, with no JavaScript to download, and accessibility built in. Use em and know the differences.
These two elements are projected into a layer above all the other UI called the top layer. The browser projects the elements from anywhere in the document, to the top when shown.
To transition this, there’s a few new CSS properties, for the full
interruptible CSS transition user experience —
transition-behavior
,
the
@starting-style
rule, and overlay
.
Combining these can feel like an incantation, but that makes it great copy and
paste. So here! Use the following snippet to enable cross fade transitions for
both <dialog>
and popover
:
Try it
/* enable transitions, allow-discrete, define timing */
[popover], dialog, ::backdrop {
transition: display 1s allow-discrete, overlay 1s allow-discrete, opacity 1s;
opacity: 0;
}
/* ON STAGE */
:popover-open,
:popover-open::backdrop,
[open],
[open]::backdrop {
opacity: 1;
}
/* OFF STAGE */
/* starting-style for pre-positioning (enter stage from here) */
@starting-style {
:popover-open,
:popover-open::backdrop,
[open],
[open]::backdrop {
opacity: 0;
}
}
While this code is effective and terse, it's often not enough customization if you want to present and dismiss dialogs differently than you do popovers. Or make the entry animation different then the exit.
Transition a dialog #
Here's a <dialog>
element with this barebones snippet applied. They look
pretty terrible out of the box, but you can
do amazing things with them.
To get started, a <dialog>
element needs to be in the HTML. A <dialog>
should be shown and hidden with buttons, one to show it can be in the page and
one to close it should be inside the dialog.
<button onclick="demo.showModal()">…</button>
<dialog id="demo">
<header>
<button title="Close" onclick="demo.close()">close</button>
</header>
</dialog>
You can enable light dismiss on a dialog and skip the close button like
<dialog closedby="any">
To animate the dialog transition:
- Two parts need animation: the
<dialog>
itself and its::backdrop
. - When a dialog is shown,
display: none
is changed todisplay: block
andtransition-behavior
enables timing this change with our animation. - When a dialog is shown, it is cloned into the top layer, this also needs to be timed with our animation.
- The
[open]
attribute is used to know when the dialog is open or closed.@starting-style
is used during the first render as starting styles.
dialog {
/* Exit Stage To */
transform: translateY(-20px);
&, &::backdrop {
transition:
display 1s allow-discrete,
overlay 1s allow-discrete,
opacity 1s ease,
transform 1s ease;
/* Exit Stage To */
opacity: 0;
}
/* On Stage */
&[open] {
opacity: 1;
transform: translateY(0px);
&::backdrop {
opacity: 0.8;
}
}
/* Enter Stage From */
@starting-style {
&[open],
&[open]::backdrop {
opacity: 0;
}
&[open] {
transform: translateY(20px);
}
}
}
With this snippet as a starting point, you can find three popular dialog experiences for you to take code or inspiration from /have-a-dialog.
The following video shows the excellent keyboard experience. It also demonstrates the interruptible nature of a CSS transition, so a user can close it anytime they want and always see a smooth interface.
Resources
Transition a popover #
Like a <dialog>
element, a popover appears over everything else in the top
layer. Light dismiss is the default, and keyboard / focus management is all done
for you.
Let's bulid it.
There's an HTML aspect to implementing the UX:
<button popovertarget="pop">?</button>
<p id="pop" popover>An overlay with additional information.</p>
Also, like a <dialog>
element, to animate the transition of a popover's
display property and insertion into the top layer, you need to combine
transition-behavior
and @starting-style
.
Notice with a popover, the open state isn't an attribute, it's a css
pseudo-class :popover-open
.
[popover] {
&, &::backdrop {
transition:
display 0.5s allow-discrete,
overlay 0.5s allow-discrete,
opacity 0.5s,
transform 0.5s;
/* Exit Stage To */
opacity: 0;
}
/* On Stage */
&:popover-open {
opacity: 1;
&::backdrop {
opacity: 0.5;
}
}
/* Enter Stage From */
@starting-style {
&:popover-open,
&:popover-open::backdrop {
opacity: 0;
}
&:popover-open {
transform: translateY(10px);
}
}
}
Resources
Transition animation for <details>
#
The disclosure element (<details>
) has been waiting for CSS primitives to
unlock its animation potential for many years.
<details>
<summary>Show disclosed content</summary>
<p>…</p>
</details>
The details element needs to transition to height: auto
from height: 0px
and
a way to target the slotted content it internally uses for the disclosure. The
new
interpolate-size
feature can be used for the height animation and
::details-content
for the
selector.
details {
inline-size: 50ch;
@media (prefers-reduced-motion: no-preference) {
interpolate-size: allow-keywords;
}
&::details-content {
opacity: 0;
block-size: 0;
overflow-y: clip;
transition: content-visibility 1s allow-discrete, opacity 1s, block-size 1s;
}
&[open]::details-content {
opacity: 1;
block-size: auto;
}
}
Resources
Bonus attribute #
If you want to connect two or more details elements and have them close each
other respectively, you can accomplish this with a shared
name
attribute on each detail element you want to be connected. Very much like a
radio group.
<details name="linked-disclosure">
<summary>Show disclosed content</summary>
<p>…</p>
</details>
<!-- name="linked-disclosure" connects these together -->
<details name="linked-disclosure>
<summary>Show disclosed content</summary>
<p>…</p>
</details>
Animated adaptive gradient text #
A bold headline in a design is often complimented with a gradient, helping draw the eye with intrigue and vividness.
Since 2015 the web has been able to create gradient text effects, and in the past 10 years, there have been some updates and enhancements: animation, user preferences and interpolation.
- Adapting the gradient text effect to light and dark themes is easy with
the
prefers-color-scheme
query - New animation updates enable gradient effects to go beyond spinning or moving gradient images around, but to change colors over time
- New interpolation updates allow those mixes to be more vivid, rich, and interesting
@property --color-1 {
syntax: "<color>";
inherits: false;
initial-value: #000;
}
@property --color-2 {
syntax: "<color>";
inherits: false;
initial-value: #000;
}
@keyframes color-change {
to {
--color-1: var(--_color-1-to);
--color-2: var(--_color-2-to);
}
}
.gradient-text {
--_space: ;
/* light mode */
--_color-1-from: yellow;
--_color-1-to: orange;
--_color-2-from: purple;
--_color-2-to: hotpink;
/* dark mode */
@media (prefers-color-scheme: dark) {
--_color-1-from: lime;
--_color-1-to: cyan;
--_color-2-from: cyan;
--_color-2-to: deeppink;
}
--color-1: var(--_color-1-from);
--color-2: var(--_color-2-from);
animation: color-change 2s linear infinite alternate;
background: linear-gradient(
to right var(--_space),
var(--color-1),
var(--color-2)
);
/* old browser support */
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
/* modern browser version */
background-clip: text;
color: transparent;
@supports (background: linear-gradient(in oklch, #fff, #fff)) {
--_space: in oklch;
}
}
That's quite a snippet 😅 How did it get to that?
Most developers making a gradient text effect are starting here:
.gradient-text {
background: linear-gradient(to right, hotpink, cyan);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
remove the prefixes #
The first update or enhancement is to remove the prefixes. Although, so older browsers continue to support the effect, add the unprefixed values after:
.gradient-text {
background: linear-gradient(to right, hotpink, cyan);
/* old browser support */
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
/* modern browser version */
background-clip: text;
color: transparent;
}
Use updated gradient interpolation spaces #
Next, improve the quality of the gradient by progressively enhancing the in
interpolation syntax
with CSS variables and @supports
.
You could alternatively repeat the gradient definition and include in oklch
in
it, which would also work great and support older browsers.
.gradient-text {
--_space: ;
background: linear-gradient(to right var(--_space), hotpink, cyan);
/* old browser support */
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
/* modern browser version */
background-clip: text;
color: transparent;
@supports (background: linear-gradient(in oklch, #fff, #fff)) {
--_space: in oklch;
}
}
Create typed <color>
properties
#
For the gradient color animation use @property
, like described in snippet #4.
The typed color values can be animated inside of a gradient, like a gradient
used with text.
@property --color-1 {
syntax: "<color>";
inherits: false;
initial-value: #000;
}
@property --color-2 {
syntax: "<color>";
inherits: false;
initial-value: #000;
}
Now --color-1
can be animated like transition: –color-1 .3s ease
or used in
keyframes. These values that can animate, can be used anywhere color is allowed,
like in a gradient text effect.
@keyframes color-change {
to {
--color-1: lime --color-2: orange;
}
}
.gradient-text {
animation: color-change 2s linear infinite alternate;
}
Make a few props, Swap em' in a dark MQ #
To keep things declarative, I've also defined color variables to hold the colors for animation.
.gradient-text {
--_color-1-from: yellow;
--_color-1-to: orange;
--_color-2-from: purple;
--_color-2-to: hotpink;
@media (prefers-color-scheme: dark) {
--_color-1-from: lime;
--_color-1-to: cyan;
--_color-2-from: cyan;
--_color-2-to: deeppink;
}
/* set our typed variables to the "from" values */
--color-1: var(--_color-1-from);
--color-2: var(--_color-2-from);
}
Put all those moments and reasons together, and we have arrived at the final snippet:
@property --color-1 {
syntax: "<color>";
inherits: false;
initial-value: #000;
}
@property --color-2 {
syntax: "<color>";
inherits: false;
initial-value: #000;
}
@keyframes color-change {
to {
--color-1: var(--_color-1-to);
--color-2: var(--_color-2-to);
}
}
.gradient-text {
--_space: ;
/* light mode */
--_color-1-from: yellow;
--_color-1-to: orange;
--_color-2-from: purple;
--_color-2-to: hotpink;
/* dark mode */
@media (prefers-color-scheme: dark) {
--_color-1-from: lime;
--_color-1-to: cyan;
--_color-2-from: cyan;
--_color-2-to: deeppink;
}
--color-1: var(--_color-1-from);
--color-2: var(--_color-2-from);
animation: color-change 2s linear infinite alternate;
background: linear-gradient(
to right var(--_space),
var(--color-1),
var(--color-2)
);
/* old browser support */
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
/* modern browser version */
background-clip: text;
color: transparent;
@supports (background: linear-gradient(in oklch, #fff, #fff)) {
--_space: in oklch;
}
}
Some years these snippets are short and sweet, but not this year. Watch out for next year's article, who knows what you'll need to know!