Portfolio Cyd Stumpel https://cydstumpel.nl/ Independent Creative Developer Thu, 15 Jan 2026 21:39:27 +0000 en-US hourly 1 https://wordpress.org/?v=6.9 https://cydstumpel.nl/wp-content/uploads/2025/03/cropped-favi-32x32.png Portfolio Cyd Stumpel https://cydstumpel.nl/ 32 32 Why we teach our students progressive enhancement https://cydstumpel.nl/why-we-teach-our-students-progressive-enhancement/ https://cydstumpel.nl/why-we-teach-our-students-progressive-enhancement/#respond Sat, 13 Dec 2025 16:50:56 +0000 https://cydstumpel.nl/?p=1445 With new CSS features seemingly coming out every week, I’ve been doing more and more progressive enhancement (PE) in my projects. It’s also a big part of what we teach our students at the Associate Degree We’re always looking for guest lecturers, contact us!Frontend Design and Development at the University of Applied Sciences in Amsterdam. […]

The post Why we teach our students progressive enhancement appeared first on Portfolio Cyd Stumpel.

]]>

With new CSS features seemingly coming out every week, I’ve been doing more and more progressive enhancement (PE) in my projects. It’s also a big part of what we teach our students at the Associate Degree We’re always looking for guest lecturers, contact us!Frontend Design and Development at the University of Applied Sciences in Amsterdam.

A famous example of PE is the escalator, when an escalator breaks down it can still be used as stairs, which satisfies the core task of an escalator: allowing people to get to a different floor.

So progressive enhancement is not about preventing failure, it’s about defining what must always work.

We teach our students to build features in three steps:

  1. First determine the core user task, and implement it in the most robust way possible in HTML
  2. Layer on additional capabilities with CSS
  3. And finally (optionally) add JavaScript when the browser allows (or needs) it

Graceful degradation is another strategy often mentioned in the same breath or confused with progressive enhancement. It assumes you start with a fully featured experience and then tries to prevent it from breaking in less capable environments. This is actually a solid strategy for things like animation or visual effects, where failure doesn’t block the core task. For core functionalities I think this approach is risky, if something breaks the feature may break with it. PE avoids this by defining what must work first and only layering on features when they’re available.

Link to:

Core tasks

Identifying the core task means determining what must succeed for a feature to be useful at all.

Most developers often bundle multiple ideas together. Filtering, for example, is usually treated as a client-side interaction problem. But at its core, filtering is really about narrowing down information. And HTML already gives us a way to do that natively: a form that submits a request and returns a refined result.

The same thing applies to disclosure patterns like FAQs, read more buttons or modals. The core task is not the animation or the toggle, it’s that the information is available in the first place. If all interactivity disappeared, could a user still read what they came for?

When you deliberately design the worst acceptable experience first, everything that comes after becomes an enhancement instead of a requirement.

Link to:

HTML first

Once you have a clear core task for your feature, the next step is to look at what HTML already provides. While HTML is often used to ‘just’ structure content, a surprising amount of built-in behaviour has been added over the years.

The <details> element, for example, offers a native way to disclose content. It works well for accordion items often used for FAQs, read-more sections and even tabs. The details element has been supported across browsers since 2020 (source: MDN), and when supported it adds keyboard support and some built-in accessibility.

But even if a browser doesn’t support the element, the content doesn’t disappear, it’s simply shown by default. HTML is quite forgiving in that way: if a browser doesn’t know an element it will still just render it as if it’s a <div>.

That’s a pattern you see in many native HTML features. Forms still submit without JavaScript. Buttons still work. Links still navigate. Even in very old browsers, these basics keep working.

Link to:

What if there’s no JS?

Progressive enhancement is often framed as making sure websites work without CSS or JavaScript. It was actually mentioned so many times during the Minor Web Design & Development that one of the students added ‘what if JavaScript is turned off?’ in the stickerpack she created.

A glossy sheet of developer-themed stickers arranged on a dark surface. The stickers include: “Minor Web Design & Development” with a triangular logo; a vertical rainbow sticker reading “Accessibility” (translated from the Dutch “Toegankelijkheid”); “NPM RUN DEV” in bold lettering; a colorful “NERD” sticker; a bald cartoon character pressing a red button; a sticker reading “ADD NONSENSE”; a terminal-style sticker saying “What if JavaScript is turned off?” (translated from Dutch); a cardboard box labeled “Flexbox”; a small green pickle illustration; and a blue cloud-shaped sticker inspired by Spongebob that says “One pull request later…”. The overall theme is web development and programming culture.
Sticker pack for the Minor Web Development

That framing puts a lot of emphasis on the failure scenarios and not enough on what the intent is of building with progressive enhancement in mind. The real goal of PE, in my opinion, is to offer a consistent experience regardless of browser, browser version, or user preferences, but the experience doesn’t have to be exactly the same everywhere.

We already accept this idea in responsive design. No client expects the desktop and mobile versions of a site to be identical; they expect them to be appropriate for the context they’re used in. Differences between browsers should be treated the same way.

Link to:

Building for the modern web

The web hasn’t been a single fixed target since it was solely running on Sir Tim Berners Lee’s computer at CERN. It runs on different devices, across different browsers, with different input methods, and under different user preferences.

Sir Tim Berners Lee at CERN

By building with progressive enhancement in mind we take that reality seriously and make sure that the most important parts of our features always work. Everything added after that; like layout, animation and interaction builds on top of a solid foundation.

It doesn’t mean your experience needs to be boring or limited, though, it actually allows you to experiment, add small delights and to use new browser features without worrying it will break things for users that can’t- or prefer not to use them yet.

Users with updated browsers might be rewarded with smoother transitions or nicer interactions, while users with older browsers aren’t punished by not being able to get the task done.

Progressive enhancement is about building something robust, that works everywhere, and then making it better where possible.

Cyd Stumpel

Cyd is a Freelance Creative Developer and teacher from Amsterdam. She teaches at the Amsterdam University of Applied Sciences and occastionally speaks at conferences and meetups.

The post Why we teach our students progressive enhancement appeared first on Portfolio Cyd Stumpel.

]]>
https://cydstumpel.nl/why-we-teach-our-students-progressive-enhancement/feed/ 0
How I turn static designs into rich experiences https://cydstumpel.nl/how-i-turn-static-designs-into-rich-experiences/ https://cydstumpel.nl/how-i-turn-static-designs-into-rich-experiences/#respond Sun, 02 Nov 2025 16:10:40 +0000 https://cydstumpel.nl/?p=1369 I’m preparing a workshop at Wey Wey Web about motion language, which made me realise how many of my habits and instincts for creating web animations have become automatic, here’s my attempt at writing them down. While I’m designing animations, I’m considering a lot of things at once. What does an animation do for the […]

The post How I turn static designs into rich experiences appeared first on Portfolio Cyd Stumpel.

]]>

I’m preparing a workshop at Wey Wey Web about motion language, which made me realise how many of my habits and instincts for creating web animations have become automatic, here’s my attempt at writing them down.

While I’m designing animations, I’m considering a lot of things at once. What does an animation do for the user experience, does it guide, clarify or just add delight? What feeling am I trying to evoke with the animation and does that fit with the other animations on the website. Which parts of this animation can I reuse elsewhere to not only keep animations consistent but also reduce the amount of code I’ll need?

Link to:

Composition

I often receive static designs, flat compositions with little to no motion direction at all. And that’s completely fine, not every web designer thinks in terms of movement, some are just not very good at animation tools, but for me that’s perfect. Designing motion is one of my favourite parts of web development, and, like Matthias Ott has preached since 2020, the web is our best tool for web design.

“We are limiting our potential for playful exploration and for creating surprising and novel solutions. And, most importantly, we are limiting our ability to make conscious, well-informed decisions going forward. By adding more and more layers of abstraction, we are breaking the feedback loop of the creative process.”

Matthias Ott – Paint­ing With the Web

Some compositions just invite motion; I can almost see how they should move from looking at a flat Figma file. Take this sketch for Hamid Sallali’s portfolio, for example:

Studio Sallali Portfolio – Sketch

The layout instantly suggested an entrance animation, something that would guide users to start scrolling. I imagined the work items as physical cards being tossed on the site’s surface. That metaphor gave me everything I needed: a starting composition, a motion direction, an easing curve (elastic) and it even helped me figure out what timing I wanted to use. Once the metaphor clicked, the rest followed naturally.

The animation starts by fading in the title and showing the start composition for the stacked cards
I slowly animate the cards to a random clamped rotation, to show there’s quite a few of them which again fitted nicely with my metaphor
While I’m throwing the cards to their correct position on the screen I’m also animating the title to the correct position and the intro text in. All elements move in a downwards direction, giving it a consistent feel
The final layout shows what was actually designed. I’m always grateful for designers who trust my judgement to experiment with motion.

After creating the entrance animation it was easier to come up with other animations, I reused the entrance animation in reverse on the work popup for example.

Link to:

Direction

I love a good metaphor, and direction is a powerful way to express it in motion. On the People standing around at Ride Out dressed in bike gearRide Out AmsterdamRide Out Website, an experimental bike store, I wanted the animations to feel like forward movement. So I leaned into transitions that travel from left to right, echoing the experience of riding and momentum. Keeping directions consistent across animations can give your site’s motion language a cohesive feeling. When elements always move in a way that makes either spatial or emotional sense, forward for progress for example, upward for discovery, downward for closure, users can start to feel the underlying logic you’ve created without actually noticing it.

Link to:

Shapes

Another way I come up with animation ideas is by looking at the shapes of elements. I’ve been obsessed with clip paths since 2018, they are such a great way to animate shapes from one state to another. Something silly like the shapes in this header that animate between shapes adds a lot of personality to an animation.

For A producer? from Talpa showing something on his phone to a colleagueTalpa StudiosTalpa Studios I used clip-paths to animate between the different aspect ratios in the header carousel, the day I found out you can round corners using clip-path: inset() was a very, very good day. The clipping animations are repeated in various ways on multiple elements on the website giving it a consistent look and feel.

Link to:

TL;DR

When you receive a static design from a designer, treat it as a carte blanche to come up with animation yourself.

If you’re not sure where to start, look at the composition and shapes. Then think about direction, duration and easing to shape the feeling you want to convey. But always keep in mind what an animation does for the user; does it clarify, guide their attention or is it just meant to delight a user?

And don’t forget to think about reusability. Repeating motion patterns build recognisability, help users anticipate interactions, and it saves you some loading time.

Cyd Stumpel

Cyd is a Freelance Creative Developer and teacher from Amsterdam. She teaches at the Amsterdam University of Applied Sciences and occastionally speaks at conferences and meetups.

The post How I turn static designs into rich experiences appeared first on Portfolio Cyd Stumpel.

]]>
https://cydstumpel.nl/how-i-turn-static-designs-into-rich-experiences/feed/ 0
Debugging in public https://cydstumpel.nl/debugging-in-public/ https://cydstumpel.nl/debugging-in-public/#respond Fri, 03 Oct 2025 12:23:33 +0000 https://cydstumpel.nl/?p=1334 During prototyping my ideas for next year’s ‘new year, new portfolio’ I came upon an interesting limitation in keyframes in combination with custom properties (CSS variables). I was prototyping an idea to translate navigation tabs to the right on scroll and to let them reappear on hover. I didn’t really like the user experience to […]

The post Debugging in public appeared first on Portfolio Cyd Stumpel.

]]>

During prototyping my ideas for next year’s ‘new year, new portfolio’ I came upon an interesting limitation in keyframes in combination with custom properties (CSS variables).

I was prototyping an idea to translate navigation tabs to the right on scroll and to let them reappear on hover.

I didn’t really like the user experience to be honest, so it will probably not make the final cut in next year’s portfolio, but the experiment is interesting regardless.

Link to:

The issue

I created keyframes that translate items to the right based on scroll position, in those same keyframes I was changing a custom property from 0 to 1 to use in a hover selector to animate them to the original position.

@property --scroll-progress {
	syntax: "<number>";
	inherits: true;
	initial-value: 0;
}

.cards {
  &:hover {
		.card {
			translate: calc(-10vw * var(--index) * var(--scroll-progress));
		}
	}

	.card {
		--scroll-progress: 0;
		animation-timeline: scroll();
		animation-name: move-right-2;
		animation-range: var(--header-scroll-range);
		animation-timing-function: var(--default-ease);
		animation-fill-mode: both;
		z-index: var(--index);
	}
}

@keyframes move-right {
	from {
		transform: translateX(0);
		--scroll-progress: 0;
	}
	to {
		transform: translateX(calc(10vw * var(--index)));
		--scroll-progress: 1;
	}
}
CSS

NB I opted for a css variable to get the index of the element here, because sibling index is not well supported enough, yet.

But what actually happened on hover was that the items jumped straight to the end point, despite the transition I had added.

I tried multiple things, like checking if the transition would work if I used a non variable value like 0.5 in the translate in stead of the custom property (this did work), changing the syntax property to other values and much more.

I turned to Bluesky for help and Nathan Knowler suggested the correct fix; to in stead of setting the keyframes to change the --scroll-progress variable on the same item as the transform keyframes, set it on a different element, using the same scroll range. The fix is updated in my codepen.

Link to:

It works, we’re done, right?

False! Because that got me thinking; why??? And I don’t have the answer yet, so please let me know if you do.

I did try a couple more things in codepen, so I know a couple of things it’s not:

  • The reason it didn’t work isn’t related to multiple items having the same keyframes and updating the custom property, because setting the keyframes on a direct parent of any of the items did work.
  • The issue is not with scroll driven animations; it’s all keyframes that have this limitation (see the last example in my codepen).
  • Setting the @property syntax declaration to other syntaxes had no effect.
  • Setting the scroll translation as a CSS variable as well had no effect on the hover effect not working.

Cyd Stumpel

Cyd is a Freelance Creative Developer and teacher from Amsterdam. She teaches at the Amsterdam University of Applied Sciences and occastionally speaks at conferences and meetups.

The post Debugging in public appeared first on Portfolio Cyd Stumpel.

]]>
https://cydstumpel.nl/debugging-in-public/feed/ 0
Start using Scroll-driven animations today! https://cydstumpel.nl/start-using-scroll-driven-animations-today/ https://cydstumpel.nl/start-using-scroll-driven-animations-today/#respond Wed, 17 Sep 2025 12:00:18 +0000 https://cydstumpel.nl/?p=1170 To celebrate scroll-driven animations finally landing in Safari 26, here are some things you probably want to know before using them. Link to: The anatomy of a scroll driven animation We don’t need the animation-duration property as the animation duration is determined by the animation-range now. This code would rotate the element with the class […]

The post Start using Scroll-driven animations today! appeared first on Portfolio Cyd Stumpel.

]]>

To celebrate scroll-driven animations finally landing in Safari 26, here are some things you probably want to know before using them.

Link to:

The anatomy of a scroll driven animation

.card {
  @media (prefers-reduced-motion: no-preference) {
    animation-timeline: view(); 
    /* or scroll(), optionally add the axis in which you're scrolling or leave empty for y */
    animation-name: rotate-card; /* your keyframe animation */
    animation-range: cover; 
    /* how long do you want your animation to run for? See sources for a visualizer */
    animation-fill-mode: both; 
    /* recommended to make sure animation doesn't return to initial state after being completed */
  }
}

@keyframes rotate-card {
  to {
    rotate: 10deg;
  }
}
CSS

We don’t need the animation-duration property as the animation duration is determined by the animation-range now. This code would rotate the element with the class .card 10 degrees over the range cover, which means: as long as the element is visible in the viewport it will animate.

Link to:

Animation ranges

So, animation ranges determine the length of the animation, the documentation on this is a difficult read, Bramus van Damme created this visualizer which helps a lot, but I also created a sketch with all the different ranges side by side.

You can combine the ranges, using for example animation-range: cover contain;, this means the animation will start at the cover start point and end at the contain end point.

But wait, we can make it more confusing! We can also add percentages! 🖖 Like: animation-range: cover 10% contain 90%;.

Let’s break this down; when you use animation-range: cover, that’s actually shorthand for animation-range: cover 0% cover 100%;. So cover 10% means that we’re starting at 10% of the cover start point, and contain 90% means we’re stopping at 90% of the contain range.

Still with me? Here’s where it get’s even worse… You can also use other CSS units in ranges! So what does animation-range: cover 10vh; do? Well it starts the range at the cover start range + 10vh of course! 😎

All kidding aside, it’s a new mental model for me, and I’m very disappointed salty that browser’s decided not to try and implement existing patterns from libs like GSAP for example, but once you get used to them a bit, they’re usable too 😅.

Link to:

View timelines vs. scroll timelines

View timelines (animation-timeline: view();) are determined by the element’s position within the viewport, scroll timelines (animation-timeline: scroll();) are determined by the scrollable container. I’m only focussing on view timelines in this article as I have barely used scroll timelines.

Link to:

View timeline name

You can set a different element to be used for the view-timeline-range, reference the element you want to use as a view timeline with view-timeline-name and in stead of using animation-timeline: view() you use the name you gave your view timeline.

.cards {
  view-timeline-name: --cards;
}

.card {
  @media (prefers-reduced-motion: no-preference) {
    animation-timeline: --cards; 
    /* or scroll(), optionally add the axis in which you're scrolling or leave empty for y */
    animation-name: rotate-card; /* your keyframe animation */
    animation-range: cover; 
    /* how long do you want your animation to run for? See sources for a visualizer */
    animation-fill-mode: both; 
    /* recommended to make sure animation doesn't return to initial state after being completed */
  }
}

@keyframes rotate-card {
  to {
    rotate: 10deg;
  }
}
CSS

Now in stead of the cards’ visibility determining the range it will be it’s outer container: .cards. The element you reference using view-timeline-name needs to either be a direct parent of the element you’re animating or you need to add the name in timeline-scope on a shared parent. Read more about using timeline-scope here.

Link to:

A simple example

See the Pen Simple scroll driven animations by Cyd Stumpel (@Sidstumple) on CodePen.

In this simple example I’ve added the code from the very start of this article, the range is set to cover so as long as the card is visible within the viewport the element is animating.

If I use the .cards section as a view-timeline reference the cards will animate on the same range; as long as the section is in view.

See the Pen Simple scroll driven animations view-timeline-name by Cyd Stumpel (@Sidstumple) on CodePen.

Link to:

Accessibility

When you’re adding animation to your website you always need to keep in mind that some people get sick from motion, and respect a user’s preferences. You can encase your code in a prefers-reduced-motion media query that’s set to no-preference.


@media (prefers-reduced-motion: no-preference) {
  /* Your animations */
}
CSS
Link to:

Fallbacks

A lot of people don’t immediately update their browsers or operating systems, and scroll driven animations aren’t supported in Firefox yet, so it’s smart to create fallbacks, I wrote an earlier article about two strategies to do just that!

Link to:

More examples

I have created a few more examples using Scroll-driven Animations on codepen, saved in this collection.

Link to:

Sources

Cyd Stumpel

Cyd is a Freelance Creative Developer and teacher from Amsterdam. She teaches at the Amsterdam University of Applied Sciences and occastionally speaks at conferences and meetups.

The post Start using Scroll-driven animations today! appeared first on Portfolio Cyd Stumpel.

]]>
https://cydstumpel.nl/start-using-scroll-driven-animations-today/feed/ 0
Being lazy with view-transition-old and -new https://cydstumpel.nl/being-lazy-with-view-transition-old-and-new/ https://cydstumpel.nl/being-lazy-with-view-transition-old-and-new/#respond Sun, 13 Jul 2025 14:23:29 +0000 https://cydstumpel.nl/?p=711 One of the most important lessons you can learn as a developer is that being lazy is often a good thing. Students sometimes laugh sheepishly at me when I tell them that, but especially if it leads to writing less (or less complicated) code, being lazy is smart. Link to: A short intro to View […]

The post Being lazy with view-transition-old and -new appeared first on Portfolio Cyd Stumpel.

]]>

One of the most important lessons you can learn as a developer is that being lazy is often a good thing. Students sometimes laugh sheepishly at me when I tell them that, but especially if it leads to writing less (or less complicated) code, being lazy is smart.

Link to:

A short intro to View Transitions

View Transitions let you animate between two DOM states (or even between pages) using CSS keyframes. If you need a more in-depth introduction, have a look at my earlier blog or the second half of my Beyond Tellerrand talk.

Link to:

The anatomy of a view transition

If you open DevTools in Chrome and inspect a View Transition, you’ll see a structure like this:

::view-transition
  ::view-transition-group(root)
    ::view-transition-image-pair(root)
      ::view-transition-old(root)
      ::view-transition-new(root)
HTML

A quick breakdown of each part:

  1. ::view-transition
    All named view transitions 1 will end up in this parent pseudo element
  2. ::view-transition-group
    Each named view transition element will get its own view transition group, ‘root’ will be equal to the given view-transition-name. In the vt-group all transforms are automatically calculated.
  3. ::view-transition-image-pair
    This element is set to isolation: isolate so the default blend modes animation of the old and new view transitions don’t mix with anything but eachother.
  4. ::view-transition-old and ::view-transition-new
    These represent, as you hopefully already expected, a snapshot of the old and new state of the named element.
Link to:

Being lazy with view-transition-old and view-transition-new

The -old and -new state of a view transition can be very useful, it’s easiest to explain with an example of filtering and sorting.

Link to:

Sorting

If you start a View Transition while changing the DOM order of items (e.g. sorting a list), the browser will automatically animate them to their new position with the help of the calculations in the view-transition-group.

Because the items exist both before and after the DOM change, they will each have a ::view-transition-old and ::view-transition-new state.

Sorting items (The old state is represented by the red rectangle, the new state by the green rectangle)
Link to:

Filtering

If you’re filtering items out of a list, the ones that disappear exist in the old state but not in the new one. They will only have a ::view-transition-old state.

Filtering items out (The old state is represented by the red rectangle, the new state by the green rectangle)

If you add items back with the same filter, the opposite is true. They only exist in the new state, so they’ll only have a ::view-transition-new.

Filtering items in (The old state is represented by the red rectangle, the new state by the green rectangle)

This might sound useless at first (it did to me), but with CSS we can check if there is only a view transition old or new state and style based on that.

::view-transition-old(.filter-item):only-child {
  animation-name: animate-out;
}

::view-transition-new(.filter-item):only-child {
  animation-name: animate-in;
}
CSS

This means you can create custom -in and -out animations for items in for example:

See the Pen View transitions – CSS only by Cyd Stumpel (@Sidstumple) on CodePen.

Link to:

Using it between pages

You can take this one step further and use it to help you animate between overview and detail pages. That’s what I did when building this website for my CSS Day talk. This way you can create smooth transitions between different pages with minimal extra work.

When going from an overview with speakers to a detail page for example, only the speaker that is clicked will have an old and a new state, the other speakers will only have an old state. When going from a detail page back to an overview only the speaker that was active will have both states. It would be even more useful if the :has selector would work on pseudo elements because then we could also adjust the stacking order of the parent group to make sure it is in front of all the other elements.

It doesn’t look like browsers will implement :has on pseudo elements: *

“Pseudo-elements are also not valid selectors within :has() and pseudo-elements are not valid anchors for :has(). This is because many pseudo-elements exist conditionally based on the styling of their ancestors and allowing these to be queried by :has() can introduce cyclic querying.”

MDN

::view-transition-group(.filter-item):has(::view-transition-old(.filter-item):only-child) {
  z-index: 1; /* Won't work 🙁 */
}
CSS

Using delays can help you avoid stacking the items on top of each other, or you could give the active item a different view transition class on click with JavaScript and use that to give the group a higher stacking context.

1 When I talk about named view transitions or named elements I mean elements that have a view-transition-name.

Cyd Stumpel

Cyd is a Freelance Creative Developer and teacher from Amsterdam. She teaches at the Amsterdam University of Applied Sciences and occastionally speaks at conferences and meetups.

Update: An issue has been opened on the CSS Working Group, we might be able to do this in the future 🤩 https://github.com/w3c/csswg-drafts/issues/12630

The post Being lazy with view-transition-old and -new appeared first on Portfolio Cyd Stumpel.

]]>
https://cydstumpel.nl/being-lazy-with-view-transition-old-and-new/feed/ 0
Two approaches to fallback CSS scroll driven animations https://cydstumpel.nl/two-approaches-to-fallback-css-scroll-driven-animations/ https://cydstumpel.nl/two-approaches-to-fallback-css-scroll-driven-animations/#respond Mon, 07 Jul 2025 15:08:46 +0000 https://cydstumpel.nl/?p=600 Scroll-driven animations are set to land in all major browsers by the end of the year, but I haven’t seen many people using them in production yet. Maybe because it still feels like an all-or-nothing feature, or maybe I’m just buried too deep in my little JS/creative dev corner again. Regardless the reason why, one […]

The post Two approaches to fallback CSS scroll driven animations appeared first on Portfolio Cyd Stumpel.

]]>

Scroll-driven animations are set to land in all major browsers by the end of the year, but I haven’t seen many people using them in production yet. Maybe because it still feels like an all-or-nothing feature, or maybe I’m just buried too deep in my little JS/creative dev corner again.

Regardless the reason why, one of my recently graduated students, Anne van Dijk, (she’s looking for a job, by the way; hit me up if you have a position available and I’ll connect you!) asked me how I built the selected work slider on my portfolio. She wanted to make something similar and had been using GSAP ScrollTrigger, but was curious about switching to CSS scroll-driven animations since GSAP was running a bit rough on mobile.

Unless she’ll be looking at scroll driven animations on an android phone, using scroll driven animations won’t help, because, at the time of writing this, it’s not supported on Safari, which means it’s not supported on iOS. But let’s not let that stop us from coding for the future in stead of coding for the past.

Link to:

Progressive enhancement vs. graceful degradation

Ever had the debate with a colleague which is better; progressive enhancement or graceful degradation? I have, my colleague Krijn Hoetmer at the Amsterdam University of Applied Sciences for example is firmly in the progressive enhancement camp. And I get it: PE encourages solid defaults and clean, efficient code, whereas GD often means piling on complexity just to force things to work where they maybe shouldn’t.

I agree with Krijn, in a perfect world PE would win. But in practice, there’s one thing that tips the scales about 8 times out of 10: clients. Clients rarely want to hear that a scroll-driven animation won’t work on certain browsers or will just look different. And that’s usually when graceful degradation ends up being the solution.

For Anne, I decided to show both approaches: one version that gracefully degrades to GSAP ScrollTrigger (with a few optimisations to reduce the mobile lag), and another that’s progressively enhanced, using CSS scroll-driven animations where supported, and falling back to a different, simpler interaction where it’s not.

Link to:

Progressive Enhanced

The Progressively Enhanced version doesn’t need any JavaScript; I’m simply overwriting or adding certain styles based on the @supports tag.

For example; if scroll driven animations are not supported I want to make the individual cards sticky in stead of the .cards__inner container that’s around it.

.cards__inner {
	display: flex;
	gap: 1rem;
	flex-direction: column;
	justify-content: center;
	align-items: center;
	@supports (animation-timeline: view()) {
		position: sticky;
		top: 0;
		height: 100lvh;
	}
}

See the Pen View transitions – CSS only by Cyd Stumpel (@Sidstumple) on CodePen.

The animation is completely different when scroll driven animations are supported (left screen) and when they’re not (right screen), but the usability is the same.

If position: sticky is not supported the cards will be just underneath each other, if display: flex is not supported the items will still just be underneath one another anyway, as that’s default behaviour for divs.

Link to:

Graceful degradation

Using this approach we need to check with JavaScript if view timelines are supported; I’m saving the value inside of a variable named needsGSAP. If needsGSAP is true, we can write a fallback with JavaScript.

const needsGSAP = !CSS.supports("animation-timeline", "view()");

See the Pen View transitions – CSS only by Cyd Stumpel (@Sidstumple) on CodePen.

Using the graceful degradation approach we can get much closer to the wanted animation, but you could ask yourself if this is maintainable in the long run.

I’m using graceful degradation on this portfolio too; turns out I’m one of those clients who can’t quite accept animations looking different across browsers (at least not yet). I’ll probably be fine with it once scroll-driven animations only fail in genuinely old browsers, rather than on most of my clients’ mobile devices.

Cyd Stumpel

Cyd is a Freelance Creative Developer and teacher from Amsterdam. She teaches at the Amsterdam University of Applied Sciences and occastionally speaks at conferences and meetups.

The post Two approaches to fallback CSS scroll driven animations appeared first on Portfolio Cyd Stumpel.

]]>
https://cydstumpel.nl/two-approaches-to-fallback-css-scroll-driven-animations/feed/ 0
Finding fish in a sea of grey and green https://cydstumpel.nl/finding-fish-in-a-sea-of-grey-and-green/ https://cydstumpel.nl/finding-fish-in-a-sea-of-grey-and-green/#respond Tue, 11 Mar 2025 13:56:28 +0000 https://0fd6c34330.nxcli.io/?p=422 At the start of this month one of my favourite De Visdeurbelwebsites went live (it has existed for a while, but I implemented the rebrand); the fish doorbell (visdeurbel.nl). The concept is really simple; a website showing a livestream at a lock in Utrecht, where users are asked to press the doorbell if they see […]

The post Finding fish in a sea of grey and green appeared first on Portfolio Cyd Stumpel.

]]>

At the start of this month one of my favourite John Oliver talking about de Visdeurbel on Last week tonightDe Visdeurbelwebsites went live (it has existed for a while, but I implemented the rebrand); the fish doorbell (visdeurbel.nl). The concept is really simple; a website showing a livestream at a lock in Utrecht, where users are asked to press the doorbell if they see a fish, creating a snapshot that’s uploaded to the backend.

Due to the enormous amount of visitors (almost twice as many on the first day compared to last year) we got 40k images in two days. To make things worse… In the first week the water in the river was still very cold and there were no fish visible on the livestream. I went through 40k grey- and green mostly completely empty images by hand, to delete the empty ones and give the servers some rest.

Day 1 of the Fish Doorbell, not much to see (cloudy day, so grey water)
Day 2 of the Fish Doorbell, still not much to see (sunny day, so green water)
Day 2…
Me slightly panicking on day 2, sending a message to my client; “The screenshots are really escalating”

We get users from all over the world and, while our biggest user group is still from The Netherlands, it’s also very popular in the United States and UK. We’ve been getting emails from retired people touring through Australia keeping up with our little lock in Utrecht.

The Fish Doorbell has gained massive popularity over the last couple of years. At the very start, 5 years ago, an actual email was sent to the lock keeper every time someone pushed the doorbell. That would be absolute madness now, as it would result in 40000 emails.

Something had to change. I was not prepared for this amount of success, partly because it’s my first year managing this website, but also because we didn’t expect to get even more users than last year.

Several Dutch news sources started reporting on visdeurbel.nl not being able to handle all the users, and my heart jumped, thinking I missed massive downtimes. Luckily they were just talking about the livestream not being able to handle the amount of users. The livestream is capped on 2000 users at a time to also help limit the amount of users. There’s a youtube livestream visible when the maximum is reached and users are not able to ring the bell anymore. Unfortunately this still resulted in way too many empty images being submitted.

Link to:

Creating a script to recognise shapes

Pressing a button is fun, we get that! And most users can’t have been aware of the strain it put on our server. The Fish Doorbell users are super loyal and well meaning, so I decided; we need to find a way to calculate the probability that there’s something visible in an image and show users a warning to not submit empty images to spare our servers.

I started searching Google for ways to detect if an image is empty, this turned up some results but they mostly required expensive Artificial Intelligence APIs. I came across a library that hasn’t been updated in 2 years called Resemble.js. This seemed like the perfect solution; it allows you to compare two images and it will return the difference.

This worked great during the day, I’d compare an image to a screenshot of the empty lock, and, if it was too similar I could be sure that the image was mostly empty and show our little warning message:

Using the term algorithm very loosely here😅

Unfortunately, during the night, the light and shadows in the lock changed so much that an empty image during the day and an empty image at night were registered as completely different images.

Fish Doorbell before sunrise
Fish Doorbell after sunrise

I asked around in my network of creative developers and came to the conclusion I needed to do something called edge detection. I gave this resource to Claude in my Cursor code editor, and asked it to turn it into JavaScript. I’m sure it can be optimised greatly, but it works quite well for my use case:

/**
 * Edge Detection Module
 * Inspired by: https://tonio73.github.io/data-science/cnn/CnnEdgeDetection-Keras-Part1.html
 */

const EdgeDetection = {
  /**
   * Apply Sobel operator for edge detection on a base64 image
   * @param {string} base64Image - Base64 encoded image
   * @param {string} mode - 'horizontal', 'vertical', or 'combined'
   * @returns {Promise<Object>} - Object containing processed image and object detection probability
   */
  detectEdges: function(base64Image, mode = 'combined') {
    return new Promise((resolve, reject) => {
      const img = new Image()
      img.onload = () => {
        try {
          // Create canvas and get image data
          const canvas = document.createElement('canvas')
          const ctx = canvas.getContext('2d')
          canvas.width = img.width
          canvas.height = img.height
          ctx.drawImage(img, 0, 0)
          
          const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
          const result = this.applySobelFilter(imageData, mode)
          
          // Put processed data back to canvas
          ctx.putImageData(result.processedData, 0, 0)
          
          resolve({
            processedImage: canvas.toDataURL(),
            objectProbability: result.objectProbability
          })
        } catch (error) {
          reject(error)
        }
      }
      img.onerror = (error) => reject(error)
      img.src = base64Image
    })
  },
  
  /**
   * Apply Sobel filter to image data and calculate object probability
   * @param {ImageData} imageData - Original image data
   * @param {string} mode - 'horizontal', 'vertical', or 'combined'
   * @returns {Object} - Object containing processed image data and object probability
   */
  applySobelFilter: function(imageData, mode) {
    // Sobel operator kernels
    const kernelX = [
      [-1, 0, 1],
      [-2, 0, 2],
      [-1, 0, 1]
    ]
    
    const kernelY = [
      [-1, -2, -1],
      [0, 0, 0],
      [1, 2, 1]
    ]
    
    const width = imageData.width
    const height = imageData.height
    const data = imageData.data
    
    // Create output image data
    const outputData = new ImageData(width, height)
    const output = outputData.data
    
    // Convert to grayscale first
    const grayscale = new Uint8ClampedArray(width * height)
    for (let i = 0; i < height; i++) {
      for (let j = 0; j < width; j++) {
        const idx = (i * width + j) * 4
        // Standard grayscale conversion
        grayscale[i * width + j] = Math.round(
          0.299 * data[idx] + 0.587 * data[idx + 1] + 0.114 * data[idx + 2]
        )
      }
    }
    
    // Variables to track edge information
    let totalEdgeStrength = 0
    let maxPossibleEdgeStrength = 0
    let significantEdgeCount = 0
    const edgeThreshold = 30 // Threshold to consider an edge significant
    
    // Apply convolution
    for (let y = 1; y < height - 1; y++) {
      for (let x = 1; x < width - 1; x++) {
        let pixelX = 0
        let pixelY = 0
        
        // Apply kernels
        for (let ky = -1; ky <= 1; ky++) {
          for (let kx = -1; kx <= 1; kx++) {
            const idx = (y + ky) * width + (x + kx)
            const kernelValue = grayscale[idx]
            
            pixelX += kernelValue * kernelX[ky + 1][kx + 1]
            pixelY += kernelValue * kernelY[ky + 1][kx + 1]
          }
        }
        
        // Calculate magnitude based on mode
        let magnitude
        if (mode === 'horizontal') {
          magnitude = Math.abs(pixelX)
        } else if (mode === 'vertical') {
          magnitude = Math.abs(pixelY)
        } else { // combined
          magnitude = Math.sqrt(pixelX * pixelX + pixelY * pixelY)
        }
        
        // Track edge information for object detection
        totalEdgeStrength += magnitude
        maxPossibleEdgeStrength += 255 // Maximum possible value
        if (magnitude > edgeThreshold) {
          significantEdgeCount++
        }
        
        // Clamp values
        magnitude = Math.min(255, Math.max(0, magnitude))
        
        // Set pixel in output
        const outIdx = (y * width + x) * 4
        output[outIdx] = magnitude     // R
        output[outIdx + 1] = magnitude // G
        output[outIdx + 2] = magnitude // B
        output[outIdx + 3] = 255       // Alpha
      }
    }
    
    // Calculate object probability based on edge information
    const totalPixels = (width - 2) * (height - 2) // Exclude border pixels
    
    // Multiple factors contribute to object probability:
    // 1. Edge density (ratio of significant edges to total pixels)
    const edgeDensity = significantEdgeCount / totalPixels
    
    // 2. Average edge strength relative to maximum possible
    const avgEdgeStrength = totalEdgeStrength / maxPossibleEdgeStrength
    
    // 3. Edge distribution - check if edges form patterns rather than noise
    // This is a simplified approach - real object detection would use more sophisticated methods
    const edgeDistributionFactor = this.calculateEdgeDistribution(outputData)
    
    // Combine factors with appropriate weights
    const objectProbability = Math.min(1.0, Math.max(0.0,
      0.4 * edgeDensity + 
      0.3 * avgEdgeStrength + 
      0.3 * edgeDistributionFactor
    ))
    
    return {
      processedData: outputData,
      objectProbability: objectProbability
    }
  },
  
  /**
   * Calculate edge distribution factor
   * Checks if edges form patterns rather than random noise
   * @param {ImageData} imageData - Processed image with edges
   * @returns {number} - Edge distribution factor (0-1)
   */
  calculateEdgeDistribution: function(imageData) {
    const width = imageData.width
    const height = imageData.height
    const data = imageData.data
    
    // Count connected edge pixels
    let connectedEdgeCount = 0
    const edgeThreshold = 30
    
    for (let y = 1; y < height - 1; y++) {
      for (let x = 1; x < width - 1; x++) {
        const idx = (y * width + x) * 4
        const pixelValue = data[idx] // Just check red channel since R=G=B
        
        if (pixelValue > edgeThreshold) {
          // Check if any neighboring pixel is also an edge
          let hasNeighbor = false
          
          // Check 8 neighbors
          for (let ny = -1; ny <= 1; ny++) {
            for (let nx = -1; nx <= 1; nx++) {
              if (nx === 0 && ny === 0) continue // Skip self
              
              const nIdx = ((y + ny) * width + (x + nx)) * 4
              if (data[nIdx] > edgeThreshold) {
                hasNeighbor = true
                break
              }
            }
            if (hasNeighbor) break
          }
          
          if (hasNeighbor) {
            connectedEdgeCount++
          }
        }
      }
    }
    
    // Calculate ratio of connected edges to all edge pixels
    const totalEdgePixels = this.countEdgePixels(imageData, edgeThreshold)
    if (totalEdgePixels === 0) return 0
    
    return connectedEdgeCount / totalEdgePixels
  },
  
  /**
   * Count total edge pixels in the image
   * @param {ImageData} imageData - Processed image with edges
   * @param {number} threshold - Threshold to consider a pixel as edge
   * @returns {number} - Count of edge pixels
   */
  countEdgePixels: function(imageData, threshold) {
    const width = imageData.width
    const height = imageData.height
    const data = imageData.data
    let count = 0
    
    for (let y = 0; y < height; y++) {
      for (let x = 0; x < width; x++) {
        const idx = (y * width + x) * 4
        if (data[idx] > threshold) {
          count++
        }
      }
    }
    
    return count
  }
}

export default EdgeDetection
JavaScript

Two more issues I ran into;

  • Even if there were fish in the images, they blended into the background pretty well.
  • The date, time and KBTS text gave some false positives

I asked Claude to write a script to make the shadows darker, the contrast higher and the exposure higher. As well as blurring out the top right and bottom left corner to hide the date and KBTS as much as possible. This is all done on a <canvas> element.

Original image on the left. Image with effects and blur in the middle. Image that the edge detector bases its results on, on the right

Everyone trying to upload an image below 32% will be shown the warning, it also adds a tag (high, medium, low) to the image based on the probability to help the ecologist, Mark van Heukelum with sorting through the images.

Images with a high probability of having ‘something’ in it

We went from ±20k images a day to approximately 1000 image uploads a day💪, we’re still seeing a lot of leafs and it’s not as good at detecting shapes when the sun is down, but it has greatly helped the amount of images we need to go through!

Cyd Stumpel

Cyd is a Freelance Creative Developer and teacher from Amsterdam. She teaches at the Amsterdam University of Applied Sciences and occastionally speaks at conferences and meetups.

The post Finding fish in a sea of grey and green appeared first on Portfolio Cyd Stumpel.

]]>
https://cydstumpel.nl/finding-fish-in-a-sea-of-grey-and-green/feed/ 0
A Practical Guide to the CSS View Transition API https://cydstumpel.nl/a-practical-guide-to-the-css-view-transition-api/ https://cydstumpel.nl/a-practical-guide-to-the-css-view-transition-api/#respond Mon, 27 Jan 2025 08:51:35 +0000 https://0fd6c34330.nxcli.io/?p=346 View transitions are, at the time I’m writing this article, supported in all major browsers, except for Firefox. Until a short time ago you could only use view transitions in a Single Page App (SPA) and not with ‘Multi Page Apps’ that use cross-document navigation, or how we used to call it: just normal websites […]

The post A Practical Guide to the CSS View Transition API appeared first on Portfolio Cyd Stumpel.

]]>

View transitions are, at the time I’m writing this article, supported in all major browsers, except for Firefox. Until a short time ago you could only use view transitions in a Single Page App (SPA) and not with ‘Multi Page Apps’ that use cross-document navigation, or how we used to call it: just normal websites that use different HTML files and native routing.

View transitions are really exciting and work really well for the most part; there are some limitations and constraints you should be aware of when you plan to use it, but it’s still a relatively easy enhancement for projects.

Link to:

The basics

View transitions make a snapshot of the current state and the next state, when it is called. By default it will add a crossfade animation between the two states. View transitions are not limited to page transitions, they can be implemented on almost any element that changes to a different state.

The CSS you need for ‘cross document navigation‘:

@view-transition {
  navigation: auto;
}
CSS

Yes, it’s that easy to add the default cross fade to your website.

Link to:

Understanding snapshots and debugging animations

When the view transition is called, either by navigating to another page, or manually (calling it with JavaScript), it will make snapshots of all view transition elements; by default this is just the root element, but, by using the view-transition-name CSS property, you can create more snapshots. You might be wondering, snapshot? But that’s what it is; it turns your page/named elements into an image. That means that some CSS properties are no longer going to have effect. For example; consider this code:

h1 {
  view-transition-name: heading;
}
CSS

If you want to change the font-size during the animation, you won’t be able to, the h1 is no longer a text element during animation, it’s a flat image and font-size doesn’t affect images. You can however change the font size of the new state; but because the snapshots are images during the animation, you need to make sure to have the same ratio and line breaks as the old state (more on this later).

A good way to understand view transitions better is using the Animations panel in the Chrome Dev Tools.

Use CMD + Shift + P in the Dev Tools, type ‘animations’ and open the animations panel

You can slow down, pause and replay view transitions using this panel. You can try this out on this super simple example of view transitions.

When you pause the animation you will see the ::view-transition pseudo elements. If you add more view-transition-names you will see more ::view-transition-groups here, root will be replaced by the name of the view transition.

You can add a custom animation to the ::view-transition-old and -new pseudo elements, this is the default example from the docs (available in the example by checking the cooler animation checkbox).

  
  /* Apply the custom animation to the old and new page states */
::view-transition-old(root) {
  animation: 0.4s ease-in both move-out;
}
  
::view-transition-new(root) {
  animation: 0.4s ease-in both move-in;
}

/* Create a custom animation */
@keyframes move-out {
  from {
    transform: translateY(0%);
  }

  to {
    transform: translateY(-100%);
  }
}

@keyframes move-in {
  from {
    transform: translateY(100%);
  }

  to {
    transform: translateY(0%);
  }
}
CSS

If you want to animate elements between pages you have to make sure that:

  1. Elements are the same ratio on both pages, they can differ in size but to animate seamlessly it needs to be the exact same ratio.
  2. Elements have the same unique view transition name, this is hopefully going to change in the future, but for now there can only be one element with a certain view-transition-name per page.
  3. Remember that you’re animating snapshot images of the actual elements on your page, not HTML elements.

If you make sure of all these steps you don’t even need to add a custom animation, CSS figures out the new positions and will take care of the animation. In this codepen I’ve called document.startViewTransition manually with a small expanding animation:

See the Pen View transitions – CSS only by Cyd Stumpel (@Sidstumple) on CodePen.

Link to:

Limitations

There are some limitations to view transitions; since we’re animating images some are a bit obvious but some are less so; here are a few that I ran into:

  • Elements need to be the exact same ratio. I mentioned this before, but this causes some big issues for text. What you might have not considered is: some text will run over more lines when animated than the initial state and some have an inline display at the start (intrinsically sized) and block display at the end (100% width); the ratio of the element for view transitions is determined by the bounding box of the text element. Jake Archibald lines up some things you can do to fix aspect ratio issues in text in this article.
  • There’s no such thing as clip-path in a ::view-transition pseudo class; if you’re animating an element that was clipped through overflow or clip path by another element it will appear unclipped in the pseudo class. You can probably fix this by making the parent element the clipped element, but in some cases you want to change the aspect ratio of an element when animating between states, I recommend doing this either when the new state is done as a separate animation or before the old state starts animating.
  • Animating stuff with border radii and backgrounds get distorted when they’re not the exact same ratio, see video. It looks *fine* when the animation is quick, but when it’s slowed down with the Dev Tools Animation panel later in the video it looks a bit weird:

Cyd Stumpel

Cyd is a Freelance Creative Developer and teacher from Amsterdam. She teaches at the Amsterdam University of Applied Sciences and occastionally speaks at conferences and meetups.

The post A Practical Guide to the CSS View Transition API appeared first on Portfolio Cyd Stumpel.

]]>
https://cydstumpel.nl/a-practical-guide-to-the-css-view-transition-api/feed/ 0
CSS Scroll-driven animations for Creative Developers https://cydstumpel.nl/css-scroll-driven-animations-for-creative-developers/ https://cydstumpel.nl/css-scroll-driven-animations-for-creative-developers/#respond Wed, 15 Jan 2025 14:52:50 +0000 https://0fd6c34330.nxcli.io/?p=264 Scroll animations have been around for years and are pretty much synonymous with Creative Development, but they have always required JavaScript to record the scroll position. This brings up a myriad of issues when done incorrectly, like; lagging animations, heavy recalculations on resize, heavy feeling websites overall, unresponsive websites in slow internet environments and much […]

The post CSS Scroll-driven animations for Creative Developers appeared first on Portfolio Cyd Stumpel.

]]>

Scroll animations have been around for years and are pretty much synonymous with Creative Development, but they have always required JavaScript to record the scroll position. This brings up a myriad of issues when done incorrectly, like; lagging animations, heavy recalculations on resize, heavy feeling websites overall, unresponsive websites in slow internet environments and much more.

But even if you “do it well”, JavaScript still runs on the main thread, the scary place where browsers process user events, CSS repaints and reflows. We want to spend as little time there as possible because when the main thread is blocked by JS for example, it will delay or block other user interactions and make websites unresponsive. Moving scroll animations from the main thread to it’s own thread with the Scroll-driven Animations module in CSS makes it faster and more performant.

CSS scroll-driven animations are only supported in Chromium browsers at the moment, which is probably why many creative developers haven’t really looked into it yet. But, after using it on my portfolio (with GSAP Scroll Trigger backups), I’m sold.

Link to:

The basics

Scroll-driven animations rely on CSS keyframe animations, but in stead of time determining the progress of the animation it’s the scroll position of the user.

At this moment there are two types of scroll based animations; view- and scroll progress. In short: scroll progress timelines have start and end values that are based on the start and end position of the scrollable container (the body by default). View progress timelines are determined by the (linked) element’s position in the viewport. For my fellow GSAP enthousiasts; it’s similar to setting a trigger; that trigger can be the element you’re animating but also some other element on the page.

Both timelines have an animation-range property, which you can use to alter the start and end positions, although the current documentation only mentions view timelines in the examples and the effects are pretty different on both elements when using the same values.

If you’re familiar with GSAP’s ScrollTrigger, the animation ranges will probably take some getting used too. Bramus van Damme created this nice visualizer to play with.

But I still had to draw it out, to have it make sense to me, maybe it helps you too:

Here’s an example of a progress bar that’s triggered by the scroll() progress, and animates the scaleX value (animating scale causes no CSS repaints) from 0 to 1:

.progress-bar {
	position: fixed;
	top: 0;
	left: 0;
	width: 100%;
	height: 5px;
	background: #ececec;
	.progress-bar__inner {
		width: 100%;
		height: 100%;
		background: hotpink;
		
		animation-timeline: scroll();
		animation-name: progress-bar;
		
		transform: scaleX(0);
		transform-origin: left center;
	}
}

@keyframes progress-bar {
  from {
		transform: scaleX(0);
  }
  to {
		transform: scaleX(1);
  }
}
CSS

Progress bars were popular on articles a few years back, the ‘problem’ (in my neurotic opinion) for this specific use case is that scroll() looks at the scrollable container for its start and end values, and will count elements like header, footer, related articles, etc as progress too.

In the example below I’m using the .content element (this element would only have the content of the article inside it) as the view timeline element, this element together with the animation-range property will now determine the start and end of the animation.

/* you have to define the view timelines in a parent component if you're not animating a direct child of the view timeline (https://developer.mozilla.org/en-US/docs/Web/CSS/timeline-scope) */
body {
  timeline-scope: --content; 
}

.content {
  view-timeline: --content;
}

.progress-bar {
	position: fixed;
	top: 0;
	left: 0;
	width: 100%;
	height: 5px;
	background: #ececec;
	.progress-bar__inner {
		width: 100%;
		height: 100%;
		background: hotpink;
		
		animation-timeline: --content;
		animation-range: entry 100svh exit;
		
		animation-name: progress-bar;
		animation-fill-mode: forwards; /* So scaleX will remain 1 after the timeline is done */
		transform: scaleX(0);
		transform-origin: left center;
	}
}

@keyframes progress-bar {
  from {
		transform: scaleX(0);
  }
  to {
		transform: scaleX(1);
  }
}
CSS

Here’s a codepen where you can see the difference (toggle the checkbox):

See the Pen Progress bar two ways by Cyd Stumpel (@Sidstumple) on CodePen.

The entry 100lvh start value only starts the animation when an element hits the top of the screen, very useful for sticky elements too!

Link to:

Applied scroll animations

I’ve noticed that a lot of the examples created with Scroll-driven animations are not really things creative developers would use. I created a collection with use cases for creative Developers.

Link to:

Marquees:

See the Pen Scroll-driven marquees (CSS only) by Cyd Stumpel (@Sidstumple) on CodePen.

Link to:

Parallax animations

See the Pen Parallax scroll-driven animations – CSS only by Cyd Stumpel (@Sidstumple) on CodePen.

Link to:

Appear animations

See the Pen Appear scroll-driven animations – CSS only by Cyd Stumpel (@Sidstumple) on CodePen.

Link to:

JavaScript API

You can’t say Creative Development without saying Javascript, so thankfully, there’s ScrollTimeline for JavaScript, which hooks into the Web Animations API. I haven’t worked at all yet with WAAPI, but I hope to check this out soon.

Don’t forget to remove the animations for people who have the prefers-reduced-motion setting activated. Don’t know how? Check out the Codepens above for more information

Link to:

Graceful degradation

In the beginning of this article I mentioned that the scroll-driven animation module only works on chrome, we can implement the graceful degradation software philosophy here, backing the CSS animations up with JavaScript:

// checks if this CSS property + value is supported:
if (!CSS.supports('animation-timeline', 'view(y 100lvh 50px)')) { 
  // backup code here	
}
JavaScript

For example, this is how I backed up the title animations on this portfolio:

import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
gsap.registerPlugin(ScrollTrigger)

if (!CSS.supports('animation-timeline', 'view(y 100lvh 50px)')) {
		const scrollHeadings = [...document.querySelectorAll('.js-scroll-heading')]
		scrollHeadings.forEach(el => {
			gsap.to(el, {
  			scrollTrigger: {
  				trigger: el,
  				endTrigger: window.innerWidth > 1024 ? el : el.closest('section'),
  				start: 'top top+=24',
  				end: window.innerWidth > 1024 ? 'center top' : 'bottom top',
  				toggleActions: 'play none play reverse',
  				scrub: window.innerWidth > 1024 ? true : false, // remove scrub on mobile, because it's janky
  			},
  			duration: 0.3,
  			scale: window.innerWidth > 1024 ? 0.3 : 0.5,
  			ease: 'none'
			})
		})
	}
JavaScript

It’s good to wrap the CSS side in @supports tags too, to make sure the animation is only applied when animation-timeline is supported:

.scroll-heading {
  position: sticky;
  top: 0;
  z-index: 10;
  
  --scale: 0.3;
  
  // sidenote: this is my sass variable for tablet screens prints something like screen and (max-width: 1024px)
  @media #{$medium-down} { 
    --scale: 0.5;
  }
  
  span {
    display: block;
    transform-origin: top center;
    @media (prefers-reduced-motion: reduce) { 
      // if user prefers reduced motion: the title is small without the animation:
      transform: scale(var(--scale));
    }
    @media (prefers-reduced-motion: no-preference) { // only implement if prefers reduced is not set;
      @supports (view-timeline: --entry-0) { // only implement if view-timeline property is supported
        animation-timeline: view(y);
        animation-range: entry calc(100lvh - var(--padding)) entry 110lvh;
        animation-timing-function: var(--default-ease);
        animation-name: scale-out;
        animation-fill-mode: forwards;
      }
    }
  }
}

@keyframes scale-out {
  to {
    transform: scale(var(--scale));
  }
}
SCSS
Link to:

Resources and tools

Link to:

Did I miss something?

Do you see any mistakes or did I miss something important? Please let me know by sending an email!

Cyd Stumpel

Cyd is a Freelance Creative Developer and teacher from Amsterdam. She teaches at the Amsterdam University of Applied Sciences and occastionally speaks at conferences and meetups.

The post CSS Scroll-driven animations for Creative Developers appeared first on Portfolio Cyd Stumpel.

]]>
https://cydstumpel.nl/css-scroll-driven-animations-for-creative-developers/feed/ 0