{"slug": "scroll-driven-animations", "title": "Scroll-Driven Animations", "summary": "The article introduces the CSS Animation Timeline API, which allows developers to create scroll-driven animations natively without JavaScript by mapping keyframe animations to an element's viewport progress instead of a time duration. It explains that by using `animation-timeline: view()`, animations scrub through keyframes based on scroll position, and it covers customization options like timing functions and spring-based easings. The post also notes that this API builds on existing CSS primitives, making it accessible to those familiar with CSS keyframe animations.", "body_md": "[Introduction]\n\nOne of the best ways to add a bit of personality to our websites is to animate things on scroll. For example, I recently created the following scroll-driven animation on the [Whimsical Animations(opens in new tab)](https://whimsy.joshwcomeau.com/) homepage:\n\nHistorically, we’ve needed to use JavaScript for this kind of effect, but an exciting new API, *Animation Timeline*, makes it possible to do this sort of thing in native CSS! ✨\n\nI’ve been experimenting with this new API for a few months, and honestly, it’s *so good.* It’s built on top of existing CSS primitives in a really elegant and natural way. In fact, if you’re familiar with CSS keyframe animations, you already know most of what you need to know!\n\nIn this blog post, I’ll show you exactly how this new API works, and we’ll explore some of the more advanced things we can do with it. I’ll also share some of the gotchas to watch out for.\n\n[Link to this heading](#the-core-concept-1)The core concept\n\nIn CSS, we can use keyframe animations to interpolate smoothly between two chunks of CSS.\n\nFor example, suppose we have the following keyframe animation:\n\n```\n@keyframes fadeIn {\n  0% {\n    opacity: 0;\n  }\n  100% {\n    opacity: 1;\n  }\n}\n```\n\nWe can use this keyframe animation to fade an element in over a certain *duration:*\n\n```\n.elem {\n  animation: fadeIn 1000ms;\n}\n```\n\nHere’s the core concept with the Animation Timeline API: **what if we map a keyframe over a scroll distance rather than a duration?**\n\nCheck this out:\n\nCode Playground\n\n```\n<style>\n  @keyframes fadeIn {\n    0% {\n      opacity: 0;\n    }\n    100% {\n      opacity: 1;\n    }\n  }\n\n  .elem {\n    background: goldenrod;\n    width: 100px;\n    height: 100px;\n    animation: fadeIn;\n    animation-timeline: view();\n  }\n</style>\n\n<p>\n  👇 Scroll down here 👇\n</p>\n<div class=\"elem\"></div>\n```\n\nInstead of transitioning from 0% (`opacity: 0;`\n\n) to 100% (`opacity: 1;`\n\n) over a set amount of time, we’re using the *element’s viewport position* as the input. Scrolling down scrubs through the keyframe animation.\n\nLet’s visualize this. We can measure the element’s progress through the viewport as a percentage. **Try scrolling through this box to see that measurement:**\n\nWe’re taking that scroll progress percentage and applying it to our keyframe animation! 🤯\n\nIt took me a minute to wrap my mind around this concept, since I’ve spent more than a decade thinking about CSS keyframes as a duration-based thing. But really, when we define a new keyframe animation with `@keyframes`\n\n, we don’t specify *what* the percentages refer to. In theory, we could use *any* input value that goes from 0% to 100%!\n\nSo, when we set `animation-timeline: view()`\n\n, we change the behaviour of the `animation`\n\nproperty so that it’s based on the element’s progress through the viewport, rather than time.\n\nThis is the most fundamental way to use this new Animation Timeline API, but we can customize the behaviour in a number of ways, as we’ll see in this blog post!\n\n[Link to this heading](#timing-functions-2)Timing functions\n\nWe can apply a custom easing curve to our scroll-driven animation with the `animation`\n\nshorthand, like any other keyframe animation!\n\nFor example, if we want our scroll-driven animation to have an “ease-out” style curve, we can slap it on like any other keyframe animation:\n\nCode Playground\n\n```\n<style>\n  @keyframes spin {\n    0% {\n      transform: rotate(0deg);\n    }\n    100% {\n      transform: rotate(360deg);\n    }\n  }\n  \n  .box {\n    --super-ease-out:\n      cubic-bezier(0.15, 0.75, 0.35, 1);\n    animation: spin var(--super-ease-out);\n    animation-timeline: view();\n  }\n</style>\n\n<p>\n  👇 Scroll down here 👇\n</p>\n<div class=\"wrapper\">\n  <div class=\"box\"></div>\n</div>\n```\n\nI’m using `cubic-bezier`\n\nto exaggerate the default `ease-out`\n\ntiming function. As you can see, the box spins quickly when it first enters the viewport, but slows as it approaches the top.\n\nWe can even use spring-based easings, thanks to the [linear() timing function](/animation/linear-timing-function/):\n\nCode Playground\n\n```\n<style>\n  @keyframes spin {\n    0% {\n      transform: rotate(180deg);\n    }\n    100% {\n      transform: rotate(0deg);\n    }\n  }\n  \n  .box {\n    --spring: linear(0, 0.01, 0.04 1.8%, 0.161 3.7%, 0.81 10.6%, 1.038, 1.181 16.4%, 1.223, 1.247 19.3%, 1.253 20.2% 21.1%, 1.232, 1.19 25.4%, 1.058 30.8%, 1.001 33.5%, 0.958 36.5%, 0.945, 0.938 39.6%, 0.936 41.6%, 0.941 43.8%, 0.999 53.9%, 1.01 56.7%, 1.015 59.7% 64.3%, 1.001 74.2%, 0.996 79.6%, 1.001);\n    animation: spin var(--spring);\n    animation-timeline: view();\n  }\n</style>\n\n<p>\n  👇 Scroll down here 👇\n</p>\n<div class=\"wrapper\">\n  <div class=\"box\"></div>\n</div>\n```\n\nThis is kind of a cursed thing to do 😂. I’m sharing this just to show how powerful this new API is, not because I think this is a good idea!\n\nThe CSS Working Group, the team that manages the CSS language deserves a lot of credit, in my opinion, for repurposing/extending CSS keyframe animations for scroll-driven animations. Rather than invent an entirely new thing from scratch, they built it on top of existing structures, which means a bunch of our pre-existing skills and knowledge can immediately be applied!\n\n[Link to this heading](#animation-ranges-3)Animation ranges\n\nIn the examples we’ve seen so far, we’re measuring the element’s scroll progress throughout its *entire* journey through the viewport. It starts the moment the very tippity top of the element enters the viewport, and it ends once the final pixel has scrolled out of view.\n\n**This is something we can customize!** The `animation-range`\n\nproperty lets us define when the range should start/end:\n\n```\n.elem {\n  animation: fadeIn;\n  animation-timeline: scroll();\n  animation-range: cover; /* 👈 default value */\n}\n```\n\nIf we change the default value to `contain`\n\n, we only start measuring once the element is *fully within the viewport:*\n\nThis can be useful in cases where we want to see the *entire* animation. With `cover`\n\n, the animation begins/completes while the element is mostly out of view.\n\nHere’s an example using elements sliding in from offscreen:\n\nCode Playground\n\n```\n<style>\n  @keyframes slideIn {\n    0% {\n      transform: translateX(-100%);\n    }\n    100% {\n      transform: translateX(0%);\n    }\n  }\n\n  .shape {\n    animation: slideIn backwards;\n    animation-timeline: view();\n    animation-range: contain;\n  }\n</style>\n\n<p>\n  👇 Scroll down here 👇\n</p>\n<div class=\"wrapper\">\n  <div class=\"shape\">\n    <img alt=\"\" src=\"/img/shape-circle.svg\" />\n  </div>\n  <div class=\"shape\">\n    <img alt=\"\" src=\"/img/shape-square.svg\" />\n  </div>\n  <div class=\"shape\">\n    <img alt=\"\" src=\"/img/shape-triangle.svg\" />\n  </div>\n  <div class=\"shape\">\n    <img alt=\"\" src=\"/img/shape-star.svg\" />\n  </div>\n</div>\n```\n\n[Link to this heading](#entry-and-exit-4)Entry and exit\n\nIn addition to `cover`\n\nand `contain`\n\n, there is also `entry`\n\nand `exit`\n\n.\n\nFor example:\n\nCode Playground\n\n```\n<style>\n  @keyframes spin {\n    0% {\n      transform: rotate(0deg);\n    }\n    100% {\n      transform: rotate(360deg);\n    }\n  }\n  \n  .box {\n    animation: spin linear;\n    animation-timeline: view();\n    animation-range: entry;\n  }\n</style>\n\n<p>\n  👇 Scroll down here 👇\n</p>\n<div class=\"wrapper\">\n  <div class=\"box\"></div>\n</div>\n```\n\nThe `entry`\n\nrange starts the moment the element peeks its head into the viewport and it ends once element’s bottom pixel has entered the viewport. We can visualize it like this:\n\nA common design pattern is to have elements fade in as they enter. The `entry`\n\nanimation range is perfect for this!\n\nCode Playground\n\n```\n<style>\n  @keyframes fadeIn {\n    0% {\n      opacity: 0;\n    }\n    100% {\n      opacity: 1;\n    }\n  }\n\n  img {\n    animation: fadeIn linear;\n    animation-timeline: view();\n    animation-range: entry;\n  }\n</style>\n\n<p>\n  👇 Scroll down here 👇\n</p>\n<img\n  alt=\"A capybara sleeping peacefully on the grass\"\n  src=\"/img/capybara/a.jpg\"\n/>\n<img\n  alt=\"A capybara chewing on some vegetation, with some white birds in the background\"\n  src=\"/img/capybara/b.jpg\"\n/>\n<img\n  alt=\"A baby capybara walking on top of an adult capybara\"\n  src=\"/img/capybara/c.jpg\"\n/>\n<img\n  alt=\"A close-up shot of a capybara, with a human nearby\"\n  src=\"/img/capybara/d.jpg\"\n/>\n```\n\nSimilarly, `exit`\n\nwill apply as the element crosses the *top* of the viewport, exiting out of view:\n\nWe can use both `entry`\n\nand `exit`\n\non the same element by specifying *multiple* keyframe animations. We do this by passing comma-separated values for each animation property:\n\nCode Playground\n\n```\n<style>\n  img {\n    animation:\n      fadeIn linear,\n      fadeOut linear;\n    animation-timeline: view(), view();\n    animation-range: entry, exit;\n  }\n</style>\n\n<p>\n  👇 Scroll down here 👇\n</p>\n<img\n  alt=\"A capybara sleeping peacefully on the grass\"\n  src=\"/img/capybara/a.jpg\"\n/>\n<img\n  alt=\"A capybara chewing on some vegetation, with some white birds in the background\"\n  src=\"/img/capybara/b.jpg\"\n/>\n<img\n  alt=\"A baby capybara walking on top of an adult capybara\"\n  src=\"/img/capybara/c.jpg\"\n/>\n<img\n  alt=\"A close-up shot of a capybara, with a human nearby\"\n  src=\"/img/capybara/d.jpg\"\n/>\n```\n\n[Link to this heading](#range-percentages-5)Range percentages\n\nThere’s a long-form syntax that allows us to precisely control where the animation range starts and ends. Check this out:\n\nCode Playground\n\n```\n<style>\n  @keyframes slideIn {\n    0% {\n      transform: translateX(-100%);\n    }\n    100% {\n      transform: translateX(0%);\n    }\n  }\n\n  .shape {\n    animation: slideIn backwards;\n    animation-timeline: view();\n    animation-range-start: cover 0%;\n    animation-range-end: cover 50%;\n  }\n</style>\n\n<p>\n  👇 Scroll down here 👇\n</p>\n<div class=\"wrapper\">\n  <div class=\"shape\">\n    <img alt=\"\" src=\"/img/shape-circle.svg\" />\n  </div>\n  <div class=\"shape\">\n    <img alt=\"\" src=\"/img/shape-square.svg\" />\n  </div>\n  <div class=\"shape\">\n    <img alt=\"\" src=\"/img/shape-triangle.svg\" />\n  </div>\n  <div class=\"shape\">\n    <img alt=\"\" src=\"/img/shape-star.svg\" />\n  </div>\n</div>\n```\n\nIn this playground, I’m specifying that the `animation-range`\n\nshould start right at the beginning of the “cover” range (right when the first pixel enters the viewport), and it should end when the element is 50% through the “cover” range (when the element is smack dab in the middle of the viewport):\n\nThis is incredibly useful, since it means we can decide *exactly* when the scroll animation starts and ends. ✨\n\nThere’s a more compact way to write this as well. We can pass all four values to the `animation-range`\n\nproperty:\n\n```\n.shape {\n  animation: slideIn backwards;\n  animation-timeline: view();\n  /* Combine start/end in one property: */\n  animation-range: cover 0% cover 50%;\n}\n```\n\nPersonally, I find this shorthand syntax pretty confusing, and I prefer to use `animation-range-start`\n\nand `animation-range-end`\n\n. But you can use whichever you prefer!\n\nFinally, if we need even *more* control, we can mix and match different animation ranges:\n\n```\n.shape {\n  animation: slideIn backwards;\n  animation-timeline: view();\n  animation-range-start: contain 0%;\n  animation-range-end: exit 50%;\n}\n```\n\n[Link to this heading](#scroll-progress-timelines-6)Scroll progress timelines\n\nSo far, we’ve been looking at *view* progress timelines, which track an element as it moves through the viewport. The Animation Timeline API gives us another primitive, *scroll* progress timelines.\n\nInstead of focusing on an individual element, *scroll* progress timelines are concerned with the scroll’s overall progress. Essentially, it maps how far a user has scrolled through the total available scrollable area.\n\nHonestly, I can’t think of a *ton* of use cases for this. The main thing that comes to mind are those progress indicators we sometimes see on blogs or news websites:\n\nCode Playground\n\n```\n<style>\n  @keyframes expand {\n    from {\n      transform: scaleX(0);\n    }\n  }\n  \n  .readingIndicator {\n    position: fixed;\n    top: 0;\n    left: 0;\n    right: 0;\n    height: 20px;\n    background: red;\n    transform-origin: left center;\n    animation: expand linear;\n    animation-timeline: scroll();\n  }\n</style>\n\n<div class=\"readingIndicator\"></div>\n\n<article>\n  <h2>What is Lorem Ipsum?</h2>\n  <p>Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.</p>\n  \n  <h2>Why do we use it?</h2>\n  <p>It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout. The point of using Lorem Ipsum is that it has a more-or-less normal distribution of letters, as opposed to using 'Content here, content here', making it look like readable English. Many desktop publishing packages and web page editors now use Lorem Ipsum as their default model text, and a search for 'lorem ipsum' will uncover many web sites still in their infancy. Various versions have evolved over the years, sometimes by accident, sometimes on purpose (injected humour and the like).</p>\n  \n  <h2>Where does it come from?</h2>\n  <p>Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots in a piece of classical Latin literature from 45 BC, making it over 2000 years old. Richard McClintock, a Latin professor at Hampden-Sydney College in Virginia, looked up one of the more obscure Latin words, consectetur, from a Lorem Ipsum passage, and going through the cites of the word in classical literature, discovered the undoubtable source. Lorem Ipsum comes from sections 1.10.32 and 1.10.33 of \"de Finibus Bonorum et Malorum\" (The Extremes of Good and Evil) by Cicero, written in 45 BC. This book is a treatise on the theory of ethics, very popular during the Renaissance. The first line of Lorem Ipsum, \"Lorem ipsum dolor sit amet..\", comes from a line in section 1.10.32.</p>\n  \n  <p>Read more on <a href=\"https://www.lipsum.com/>Lipsum.com</a></p>\n</article>\n```\n\nAs you scroll through this website, a red bar grows along the top, showing how far you’ve made it through the article.\n\nThis does feel a *little* bit redundant to me, since we have scrollbars for this purpose 😅. But I can’t really think of many other reasons you’d want to measure *overall* scroll progress.\n\n[Link to this heading](#linked-timelines-7)Linked timelines\n\nSo far, all of the examples we’ve seen involve measuring an element’s scroll progress as a percentage and applying it to a keyframe animation *on that same element*.\n\nThat’s usually what we want, but in some cases, we want to separate the element we *measure* from the element we *animate*. One element’s scroll position can scrub through another element’s keyframe animation.\n\nHere’s what that looks like:\n\nIn this example, `.square`\n\nuses sticky positioning, so it doesn’t move through the viewport at all. We’re tracking the `.content`\n\nelement (the paragraph on the right) and using its progress to fade `.square`\n\nin and out.\n\n**Here’s how this works:** on the element we want to track (`.content`\n\n), we set `view-timeline: --tracked-elem`\n\n. “--tracked-elem” is a variable, and I can name it whatever I want. This creates a new “view progress timeline”. Any other elements can subscribe to this element’s progress through the viewport.\n\nOn the element we want to animate (`.square`\n\n), we set `animation-timeline`\n\nto `--tracked-elem`\n\nrather than `view()`\n\n. That way, we use an existing *named* view progress timeline, rather than initializing its own view progress timeline.\n\n**There’s a big gotcha here:** the variable names we create, like `--tracked-elem`\n\n, are not global. They can only be referenced by the element that creates it (`.content`\n\n, in this case) and its descendants.\n\nThis is a problem for us. The element we want to animate is not a descendant of the element we want to track:\n\n```\n<main>\n  <div class=\"left col\">\n    <!-- We want to access the --tracked-elem variable here: -->\n    <div class=\"square\"></div>\n  </div>\n  <div class=\"right col\">\n    <!-- We create the --tracked-elem variable here: -->\n    <p class=\"content\">← A square appears!</p>\n  </div>\n</main>\n```\n\nFortunately, the lovely folks at the CSSWG foresaw this issue, and they gave us an escape hatch: the `timeline-scope`\n\nproperty.\n\nThis property essentially allows us to *declare* a variable at a higher level, which will then be reassigned somewhere down the tree. Here’s the full code, with added comments to clarify what’s going on:\n\n```\n<style>\n  main {\n    /* Instantiate a new variable here: */\n    timeline-scope: --tracked-elem;\n  }\n\n  .content {\n    /*\n      Create a new view progress timeline and\n      assign it to the variable:\n    */\n    view-timeline: --tracked-elem;\n  }\n  .square {\n    animation: fadeIn backwards, fadeOut forwards;\n    /* Reference the named view progress timeline: */\n    animation-timeline: --tracked-elem, --tracked-elem;\n    animation-range: entry, exit;\n  }\n</style>\n\n<main>\n  <div class=\"left col\">\n    <div class=\"square\"></div>\n  </div>\n  <div class=\"right col\">\n    <p class=\"content\">← A square appears!</p>\n  </div>\n</main>\n```\n\nI’m adding the `timeline-scope`\n\ndeclaration to `<main>`\n\nbecause it’s the nearest shared ancestor. It ensures that `--tracked-elem`\n\nwill be available for both the element we want to track and the element we want to animate.\n\n[Link to this heading](#scratching-the-surface-8)Scratching the surface\n\nWe’ve seen a few examples of what we can do with the new `animation-timeline`\n\nAPI, but we can only cover so much in a single blog post. 😅\n\nFor the past year and a half, I’ve been focused on creating the ultimate animation resource. It’s a comprehensive collection of the skills needed to create all sorts of whimsical effects using HTML/CSS, JavaScript, SVG, and Canvas.\n\nIt’s called *Whimsical Animations*.\n\nThis course is broken into 4 main sections. Part 3 is all about advanced user interactions, and we go *much deeper* into scroll-driven animations. It also covers a bunch of other cool stuff like View Transitions and cursor-tracking effects, like this lil’ guy:\n\nI’m so excited to share all of my animation secrets 😄. You can learn more here:\n\n### Last updated on\n\nMay 12th, 2026", "url": "https://wpnews.pro/news/scroll-driven-animations", "canonical_source": "https://www.joshwcomeau.com/animation/scroll-driven-animations/", "published_at": "2026-04-28 12:00:00+00:00", "updated_at": "2026-05-22 14:49:36.965650+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Whimsical Animations", "Animation Timeline"], "alternates": {"html": "https://wpnews.pro/news/scroll-driven-animations", "markdown": "https://wpnews.pro/news/scroll-driven-animations.md", "text": "https://wpnews.pro/news/scroll-driven-animations.txt", "jsonld": "https://wpnews.pro/news/scroll-driven-animations.jsonld"}}