Back to Lab
RAXXO Studios 9 min read No time? Make it a 1 min read

Starting Style and Entry Animations in CSS Without JavaScript

CSS
9 min read
TLDR
×
  • @starting-style ships in every modern browser as of 2024
  • Five patterns: dialog, sheet, list reveal, toast, route swap
  • Pair it with transition-behavior and @starting-style for display:none elements
  • Zero JavaScript for entry animations cuts bundle weight and avoids hydration flashes

I deleted about 140 lines of JavaScript animation code from my Shopify theme last month and nothing broke. The entry animations still play. The dialog still fades in. The reason is `@starting-style`, a CSS rule that finally lets you animate elements appearing from `display: none` without a single line of script.

What @starting-style Actually Solves

For years the entry animation problem was annoying in a specific way. CSS transitions only fire when a property changes between two rendered states. If an element starts at `display: none`, there is no first state to transition from. The element just pops into existence. So everyone reached for JavaScript: add a class on the next animation frame, wait for a timeout, toggle visibility, attach a `transitionend` listener to clean up. It worked but it was fragile and it bloated the bundle.

`@starting-style` gives you that missing first state. You write the starting values inside a `@starting-style` block, and the browser uses them as the transition origin when the element first renders or when it switches away from `display: none`. Here is the whole idea in eight lines:


.card {
  opacity: 1;
  transition: opacity 0.3s ease;
}
@starting-style {
  .card {
    opacity: 0;
  }
}

When the card appears, it transitions from opacity 0 to opacity 1. No class toggle, no requestAnimationFrame dance, no listener.

The piece that makes this work for elements that genuinely hide is `transition-behavior: allow-discrete`. Properties like `display` and `overlay` are discrete, meaning they normally flip instantly with no in-between. `allow-discrete` tells the browser to delay flipping `display: none` until the transition finishes, so your fade-out actually plays before the element vanishes. That combination, `@starting-style` plus `allow-discrete`, covers the full lifecycle: enter and exit.

Browser support landed across all three engines through 2024. Chrome shipped it in 117, Safari in 17.5, Firefox in 129. As of early 2026 I treat it as a baseline feature and only add a JavaScript fallback for analytics dashboards where I know old corporate browsers linger. For consumer-facing storefronts I ship it raw. The progressive enhancement story is clean too: if a browser does not understand the rule, the element simply appears with no animation. Nothing breaks. That is the kind of failure mode I want.

Pattern One and Two: Dialog and Sheet

The native `

` element is where `@starting-style` shines hardest. Before, animating a dialog open was a mess because `showModal()` flips `display` and adds the top layer in one synchronous step. Now I write three blocks and I am done.


dialog {
  opacity: 1;
  translate: 0 0;
  transition: opacity 0.25s, translate 0.25s, display 0.25s allow-discrete, overlay 0.25s allow-discrete;
}
dialog[open] {
  opacity: 1;
}
@starting-style {
  dialog[open] {
    opacity: 0;
    translate: 0 -20px;
  }
}

The dialog slides down 20px and fades in when opened. Closing reverses it because the `display` and `overlay` transitions have `allow-discrete`. I use this on every product quick-view modal in my store. The whole interaction is `dialog.showModal()` from a button, which is the one line of JavaScript the native element requires, and it is not animation code.

The bottom sheet is pattern one rotated 90 degrees. Mobile users expect a panel that slides up from the bottom edge. I build it on the same `

` base but change the transform axis and pin it to the bottom with positioning.


dialog.sheet {
  translate: 0 0;
  transition: translate 0.3s ease, display 0.3s allow-discrete, overlay 0.3s allow-discrete;
}
@starting-style {
  dialog.sheet[open] {
    translate: 0 100%;
  }
}

The sheet starts fully offscreen at `translate: 0 100%` and slides into place. On close it slides back down. I tested this against the old JavaScript version on a mid-range Android phone and the CSS version held a steady 60fps where the JS toggle dropped frames during the class-add render. The browser compositor handles `translate` and `opacity` on its own thread, so once the animation starts the main thread is free.

One detail worth knowing: the `::backdrop` pseudo-element gets its own `@starting-style` block. If you want the dim overlay behind a dialog to fade in rather than snap, you style `dialog::backdrop` and `@starting-style dialogopen]::backdrop` separately. I forgot this on my first build and the backdrop popped while the panel slid, which looked broken. Five extra lines fixed it. If you are also reworking how panels and overlays transition between pages, the [View Transitions API patterns post covers the page-level version of this same idea.

Pattern Three and Four: List Reveal and Toast

Staggered list reveals used to need a JavaScript loop setting a delay per item. You can get most of the effect with pure CSS by combining `@starting-style` with `transition-delay` driven by a custom property.


.item {
  opacity: 1;
  translate: 0 0;
  transition: opacity 0.4s ease, translate 0.4s ease;
  transition-delay: calc(var(--i) * 60ms);
}
@starting-style {
  .item {
    opacity: 0;
    translate: 0 12px;
  }
}

I set `--i` as an inline style on each item in my Liquid template, so item one waits 0ms, item two waits 60ms, and so on. The cascade plays automatically when the list first renders. On a product grid with 12 cards the full reveal takes about 700ms and feels deliberate rather than slow. The honest limitation: this fires on first render, not when you filter or sort a list in place. For that you want the View Transitions API instead, because reordering existing DOM nodes is a different problem than introducing new ones.

Toasts are the pattern I reach for most because notifications appear and disappear constantly. A toast needs to slide in, hold, and slide out, and the old way meant a timeout plus a class toggle plus a cleanup listener. Now the enter is `@starting-style` and the exit is `allow-discrete`.


.toast {
  opacity: 1;
  translate: 0 0;
  transition: opacity 0.3s, translate 0.3s, display 0.3s allow-discrete;
}
@starting-style {
  .toast {
    opacity: 0;
    translate: 0 -16px;
  }
}

I still remove the toast from the DOM after a few seconds with one `setTimeout`, but the slide-out animation runs entirely in CSS because removing the element triggers the discrete `display` transition. The result is a toast system that is maybe 20 lines of CSS and a single timer. My old version was a 90-line module with its own event emitter. I shipped fewer bytes and the animation got smoother because the compositor handles it. If you schedule social posts and want toasts confirming a queued post, this pairs nicely with a tool like Buffer where the feedback loop matters more than the heavy framework around it.

Pattern Five: Route Transitions Without a Framework

The last pattern is the loosest because route transitions usually belong to the View Transitions API. But on a multi-page site where each page is a real document load, I use `@starting-style` to fade the main content in on every navigation. No router, no framework, no hydration.


main {
  opacity: 1;
  translate: 0 0;
  transition: opacity 0.35s ease, translate 0.35s ease;
}
@starting-style {
  main {
    opacity: 0;
    translate: 0 8px;
  }
}

Because `@starting-style` runs when an element first renders, and a full page load renders everything fresh, the `

` block fades up on every navigation. There is no flash of unstyled content because the starting state is opacity 0, which the browser applies before paint. On my Shopify storefront, where pages are server-rendered Liquid, this gave me an app-like feel without adding a single kilobyte of JavaScript. I measured the difference: the JS-driven page-fade I had before added 6kb gzipped and ran after first paint, which meant a visible jump on slow connections. The CSS version is in the stylesheet that already loads, so there is no extra request and no jump.

The one caveat is that this fires on hard navigations, not on history back-forward when the browser restores from its page cache. Restored pages skip the starting state because they are not a fresh render. I decided that was fine. A back navigation that restores instantly is a better experience than one that fades, so the inconsistency is actually correct behavior.

I run this stack on Shopify where the platform gives me server-rendered HTML and I layer CSS on top. The whole point is that the animation logic lives in CSS the browser already parsed, not in script that has to download, parse, and execute before anything moves. If you want the broader system I use to wire these patterns into a real production theme with an AI workflow, the Claude Blueprint walks through how I structure the whole build so each piece stays small and replaceable.

Bottom Line

`@starting-style` moved entry animations from the JavaScript layer back into CSS where they belong. The five patterns I covered (dialog open, sheet slide-in, staggered list reveal, toast, and route fade) replaced roughly 140 lines of script across my sites with maybe 60 lines of CSS, and the animations got smoother because the compositor handles them off the main thread.

The mental model is simple once it clicks. `@starting-style` is the missing first frame. `allow-discrete` is what lets `display: none` elements animate out instead of snapping. Together they cover the full enter-and-exit lifecycle with no listeners and no cleanup. Browser support is a baseline as of 2026, and the failure mode for older browsers is a clean instant appearance, so there is nothing to fear about shipping it.

Start with one pattern. Pick your most-used dialog or toast, delete the JavaScript animation, and write the three CSS blocks. Once you feel how little code it takes you will hunt for the rest. If you want the page-level companion to this, read the View Transitions API patterns next.

This article contains affiliate links. If you sign up through them, I may earn a small commission at no extra cost to you. (Ad)

Stay in the loop
New tools, drops, and AI experiments. No spam. Unsubscribe anytime.
Back to all articles