{"slug": "the-big-gotcha-with-starting-style", "title": "The Big Gotcha With @starting-style", "summary": "The article explains the new CSS `@starting-style` at-rule, which allows developers to use CSS transitions for enter animations when elements are dynamically added to a page, overcoming the traditional limitation that transitions only work on property changes. However, it highlights a key problem: styles defined within `@starting-style` are handled differently by the browser than those in `@keyframes`, leading to specificity conflicts. The post promises to explore this issue and offer workarounds, while also teaching about CSS specificity.", "body_md": "[Introduction]\n\nHave you heard of the `@starting-style`\n\nat-rule? It’s an interesting new tool that lets us use *CSS transitions* for enter animations.\n\nFor example, let’s suppose we have some UI where elements get added dynamically to the page, and we want them to fade in:\n\nCode Playground\n\n```\n<style>\n  @keyframes fadeFromTransparent {\n    from {\n      opacity: 0;\n    }\n  }\n  .box {\n    animation: fadeFromTransparent 1000ms;\n  }\n</style>\n\n<button id=\"triggerBtn\">\n  Add Element\n</button>\n```\n\nWhen you click the “Add Element” button, a new purple square is generated and added to the page, and a CSS keyframe animation fades it in over 1 second.\n\nHistorically, the big limitation with CSS *transitions* has been that they only apply when a targeted CSS property changes from one value to another. If we want a property to animate when an element is *created*, we’ve needed to use CSS keyframe animations, like I’m doing in the example above.\n\nThe new `@starting-style`\n\nAPI is a workaround for this limitation. We can provide an *alternative set* of CSS declarations. When the element is created, it’ll immediately transition from these initial styles.\n\nCheck it out:\n\nCode Playground\n\n```\n<style>\n  .box {\n    opacity: 1;\n    transition: opacity 1000ms;\n    \n    @starting-style {\n      opacity: 0;\n    }\n  }\n</style>\n\n<button id=\"triggerBtn\">\n  Add Element\n</button>\n```\n\nEach `.box`\n\nelement gets initialized with `opacity: 0`\n\n, set within the `@starting-style`\n\nblock. Right after the element is created, that declaration is removed, triggering a CSS transition to `opacity: 1`\n\n, set within the main styles.\n\n**This is pretty cool, but there’s a catch.** The CSS within `@starting-style`\n\nisn’t handled by the browser in the same way as the CSS within `@keyframes`\n\n, and this can lead to some problems. 😬\n\nIn this blog post, we’ll dig into the issue I found with this API and explore some workarounds. In the process, we’ll learn quite a bit about CSS specificity, so even if you’re not particularly interested in `@starting-style`\n\n, I bet this’ll still be worth your while!\n\n[Link to this heading](#the-specificity-problem-1)The specificity problem\n\nIf you’ve worked with CSS for a while, you probably know about *specificity.* When our CSS contains rules that conflict with each other, the browser has a system to work out which CSS should actually be applied.\n\nFor example, consider this setup:\n\n```\n<button class=\"primary-button\">\n  Hello World\n</button>\n\n<style>\n  button {\n    background-color: transparent;\n  }\n\n  .primary-button {\n    background-color: blue;\n  }\n</style>\n```\n\nThis snippet contains two rules, and they both match that `<button>`\n\nelement. Each rule sets the `background-color`\n\nproperty to a different value. The button can’t simultaneously be both `transparent`\n\nand `blue`\n\n. How does the browser decide which value to apply?\n\nAccording to the specificity rules, class selectors like `.primary-button`\n\nare *more specific* than tag selectors like `button`\n\n. This means that they emerge victorious from this confrontation, and our button would be painted blue.\n\nIn addition to the hierarchy of specificity (tag → class → id), there are also *different groups* of CSS, with different priority levels. This is technically a distinct concept from specificity, but it feels to me like a zoomed-out version of the same thing.\n\nFor example, every browser comes with a built-in set of CSS styles (“user-agent” styles). This is why headings are bold by default, and why the `<blockquote>`\n\nelement looks different than a `<p>`\n\nelement. Instead of doing specificity math for each built-in style, the browser treats them as an entirely separate collection of CSS. They get applied first, and any CSS *we* write, no matter its specificity, will overwrite it.\n\nAnother example is the `!important`\n\nflag. Any CSS with this flag will be moved to its own high-priority collection of styles, automatically winning over any styles *without* `!important`\n\n, no matter their specificity.\n\n**What about keyframe animations?** These styles are *also* a distinct collection. That’s why we can do stuff like this:\n\n```\n<style>\n  @keyframes fadeFromTransparent {\n    from {\n      opacity: 0;\n    }\n  }\n\n  h1 {\n    animation: fadeFromTransparent 1000ms;\n  }\n\n  #title {\n    opacity: 1;\n  }\n</style>\n\n<h1 id=\"title\"></h1>\n```\n\nThis is interesting, when we think about it. Our `fadeFromTransparent`\n\nanimation changes the `opacity`\n\nproperty, and we’re doing it from within a tag selector (`h1`\n\n). But we’re *also* setting `opacity`\n\nto `1`\n\nin an ID selector (`#title`\n\n). By the rules of specificity, that `opacity: 1;`\n\nshould overwrite the fade-in animation!\n\nThis works because the CSS declarations within keyframe animations are *promoted to their own collection.* This collection has the second-highest priority, just below `!important`\n\n. This means that our keyframe animations will almost always work. We don’t have to worry about any of this stuff when we use CSS keyframes.\n\n**But the same can’t be said for @starting-style!** Unlike keyframe animations, the styles inside the\n\n`@starting-style`\n\nblock *aren’t*promoted. This means that the standard specificity rules apply.\n\nAs a result, our enter animation won’t run in cases like this:\n\nCode Playground\n\n```\n<style>\n  h1 {\n    transition: opacity 500ms;\n\n    @starting-style {\n      opacity: 0;\n    }\n  }\n\n  #title {\n    opacity: 1;\n  }\n</style>\n\n<h1 id=\"title\">\n  I don’t fade in :(\n</h1>\n```\n\nWhen this heading is created, the browser runs its specificity calculations. Since `#title`\n\nis more specific than `h1`\n\n, the element is initialized with an opacity of `1`\n\n, not `0`\n\n.\n\nCSS transitions are only triggered when the *applied* styles change. In this setup, the `opacity`\n\nvalue is never actually set to 0, so there is no transition. The heading is painted immediately at full opacity.\n\nAdmittedly, this is a pretty contrived example, and most modern CSS approaches (Tailwind, styled-components, CSS Modules, BEM, etc) will protect you from these sorts of specificity issues. **But even if you use one of these approaches, this gotcha can still getcha.**\n\nLet’s look at a real-world example I ran into recently. In my [upcoming course on whimsical animations(opens in new tab)](https://whimsy.joshwcomeau.com/), we build this particle effect:\n\nWhen the user clicks the “Like” button, it generates 15-20 particles. They all start from the very center of the button and then expand outwards in a random direction by a random amount. This motion is accomplished using CSS transforms. So, for example, a particle might go from `transform: translate(0px, 0px)`\n\n(perfectly centered) to `transform: translate(42px, -55px)`\n\n(up and to the right).\n\n**When I tried to use @starting-style for this, it didn’t work.** I spent a good few minutes completely baffled by it.\n\nThis particle effect is deceptively complex, and the full implementation is *far* too big to fit within this blog post, but I’ve done my best to pluck out the bare essentials, so that we can see this issue in action:\n\nCode Playground\n\n```\n<style>\n  .particle {\n    transition: transform 500ms;\n    \n    @starting-style {\n      transform: translate(0px, 0px);\n    }\n  }\n</style>\n\n<button class=\"particleButton\">\n  <svg\n    viewBox=\"0 0 24 24\"\n    fill=\"none\"\n    aria-hidden=\"true\"\n  >\n    <path\n      d=\"\n        M 3.5 5.5\n        C 8.6 1.3\n          11.9 7.4\n          12 7.4\n        C 12.2 7.4\n          15.5 1.3\n          20.4 5.4\n        C 26.9 10.9\n          13.5 21.8\n          12 21.8\n        C 10.6 21.8\n          -2.8 10.9\n          3.7 5.4\n        Z\n      \"\n      stroke=\"white\"\n      stroke-width=\"2\"\n      stroke-linecap=\"round\"\n    />\n  </svg>\n  <span class=\"visually-hidden\">Like this post</span>\n</button>\n```\n\nTry clicking the button, and notice that the particles don’t “pop”. They appear immediately in their final position, instead of animating from their starting position. Here’s what the *expected* result should be:\n\nIf we examine the code, we see that we’re setting the initial position in CSS with `@starting-style`\n\n. The ending position is dynamically generated for each particle, and set within `/index.js`\n\n:\n\n```\nparticle.style.transform = `translate(\n  calc(cos(${angle}deg) * ${distance}px),\n  calc(sin(${angle}deg) * ${distance}px)\n)`;\n```\n\n**The problem, once again, is related to specificity.** When we set a style in JavaScript like this, it gets applied as an *inline style,* which is *much* more specific than the initial position, set in a CSS class (`.particle`\n\n). As a result, the starting styles never actually get applied to the particles.\n\nThis is a real-world example of the sort of subtle issue I ran into when I tried to use `@starting-style`\n\nin my own work. I consider myself reasonably adept at navigating specificity issues, but even still, this stuff really catches me off-guard!\n\n[Link to this heading](#solutions-2)Solutions\n\nSo, that’s the problem. Thanks for bearing with me through that rather lengthy explanation. 😅\n\nLet’s talk about how we can address it.\n\n[Link to this heading](#one-the-nuclear-option-3)1. The nuclear option\n\nOne option to solve this problem is to increase the priority of the `@starting-style`\n\ndeclaration using `!important`\n\n:\n\n```\n.particle {\n  transition: transform 500ms;\n\n  @starting-style {\n    transform: translate(0px, 0px) !important;\n  }\n}\n```\n\nAs we briefly saw earlier, `!important`\n\npromotes the given CSS declaration to the highest-priority group, superseding all specificity calculations.\n\nThis works great, but whenever I use `!important`\n\n, it feels a bit like making a deal with the devil. It solves my problem *today*, but at a significant potential maintenance cost. It reduces the number of options available to us in the future, when we run into other specificity issues.\n\nGranted, in this particular case, it’s not *quite* so bad, since the starting styles are removed automatically right after the element is created. But it still doesn’t feel like a *great* solution.\n\n[Link to this heading](#two-css-custom-properties-4)2: CSS custom properties\n\nA clever solution to this problem is to change how the final `transform`\n\ndeclaration is applied, using custom properties:\n\n```\n/* /styles.css */\n.particle {\n  transform: translate(var(--x), var(--y));\n  transition: transform 500ms;\n\n  @starting-style {\n    transform: translate(0px, 0px);\n  }\n}\njs\n/* /index.js */\nconst angle = random(0, 360);\nconst distance = random(32, 64);\n\nparticle.style.setProperty(\n  '--x',\n  `calc(cos(${angle}deg) * ${distance}px)`\n);\nparticle.style.setProperty(\n  '--y',\n  `calc(sin(${angle}deg) * ${distance}px)`\n);\n```\n\nIn our JavaScript file, we create two new CSS custom properties (also known as CSS variables), `--x`\n\nand `--y`\n\n. We can then reference these values in our `.particle`\n\nclass styles!\n\nAs a result, our two `transform`\n\ndeclarations have the same specificity, and since the `@starting-style`\n\nis placed underneath the end `transform`\n\ndeclaration, everything works the way we’d expect.\n\nIn my opinion, this solution is very elegant. It sidesteps the core specificity issue in a very graceful way. **But I don’t want my code to be elegant and graceful!** I want my code to be as simple and basic as possible, so that it can be easily understood by the least-experienced members of my team, and so that I don’t have to burn a bunch of calories to figure out what’s going on when I revisit this code down the line.\n\n[Link to this heading](#three-using-keyframes-instead-5)3: Using keyframes instead\n\nInstead of escalating things with `!important`\n\nor using a clever custom-property approach, we can always fall back on trusty CSS keyframes!\n\nCode Playground\n\n```\n<style>\n  @keyframes fromRestingPosition {\n    from {\n      transform: translate(0px, 0px);\n    }\n  }\n  .particle {\n    animation: fromRestingPosition 300ms;\n  }\n</style>\n\n<button class=\"particleButton\">\n  <svg\n    viewBox=\"0 0 24 24\"\n    fill=\"none\"\n    aria-hidden=\"true\"\n  >\n    <path\n      d=\"\n        M 3.5 5.5\n        C 8.6 1.3\n          11.9 7.4\n          12 7.4\n        C 12.2 7.4\n          15.5 1.3\n          20.4 5.4\n        C 26.9 10.9\n          13.5 21.8\n          12 21.8\n        C 10.6 21.8\n          -2.8 10.9\n          3.7 5.4\n        Z\n      \"\n      stroke=\"white\"\n      stroke-width=\"2\"\n      stroke-linecap=\"round\"\n    />\n  </svg>\n  <span class=\"visually-hidden\">Like this post</span>\n</button>\n```\n\nLook at that! This approach works great. It only requires a couple lines of very basic CSS, and will be very easy to maintain. *Plus,* this approach will will work in virtually all browsers. `@starting-style`\n\nhas *pretty good* browser support, but it’ll be many, many years until it’s as universal as keyframe animations.\n\nI think the appeal of `@starting-style`\n\nis that developers are already super comfortable with transitions, while CSS keyframes feel less flexible and less intuitive. But with modern CSS, I would argue that CSS keyframes are just as flexible as transitions, and *even more* powerful! In my opinion, keyframe animations are *super* underrated.\n\nI recently wrote about the cool things that we can do with keyframe animations. You can learn more here:\n\n[Link to this heading](#syntactic-sugar-6)Syntactic sugar?\n\nIt seems to me that `@starting-style`\n\ndoesn’t really open any new doors in terms of the sorts of animations we can create on the web. So far, all of the examples I’ve seen could be accomplished using CSS keyframe animations. And as we’ve seen in this blog post, it often winds up being simpler with keyframes!\n\nThis makes me think that maybe I’m missing something. Lots of the examples I see online combine `@starting-style`\n\nwith other modern CSS features, like `transition-behavior: allow-discrete`\n\nand `interpolate-size: allow-keywords`\n\n. But as far as I can tell, all of that stuff works equally well with keyframe animations. 🤔\n\nIf you know of something that can be accomplished exclusively using `@starting-style`\n\n, please let me know! You can [shoot me an email](/contact/) or [share it with me on Bluesky(opens in new tab)](https://bsky.app/profile/joshwcomeau.com).\n\nAnd either way, a big “thank you” to all of the people who were involved in creating this and so many other modern CSS features. The pace of development has been *unreal* these last few years, and I can only imagine how much hard work goes into this. I’m very grateful for all of the new tools you’ve given us. ❤️\n\n[Link to this heading](#an-exciting-announcement-7)An exciting announcement\n\nIf you’re familiar with my work, you know I care *a lot* about animations and interactions. I think they’re one of the most important ingredients when it comes to making polished products that feel great to use.\n\n**For the past 18 months, I’ve been working on something special.** It’s an interactive online course called [Whimsical Animations(opens in new tab)](https://whimsy.joshwcomeau.com/).\n\nMy goal with this course is to share everything I’ve learned about how to design and build next-level animations. If you’ve ever wondered how I did something on this blog (or in my other projects), there’s a very good chance that you’ll learn about it in my course.\n\nCheck it out:\n\n### Last updated on\n\nMay 5th, 2026", "url": "https://wpnews.pro/news/the-big-gotcha-with-starting-style", "canonical_source": "https://www.joshwcomeau.com/css/starting-style/", "published_at": "2025-09-22 11:30:00+00:00", "updated_at": "2026-05-22 14:52:24.497995+00:00", "lang": "en", "topics": ["developer-tools"], "entities": [], "alternates": {"html": "https://wpnews.pro/news/the-big-gotcha-with-starting-style", "markdown": "https://wpnews.pro/news/the-big-gotcha-with-starting-style.md", "text": "https://wpnews.pro/news/the-big-gotcha-with-starting-style.txt", "jsonld": "https://wpnews.pro/news/the-big-gotcha-with-starting-style.jsonld"}}