Transitions✨ Premium

Morph Transition

An element that really goes from display: flex to display: none — and back — without losing its animation: it shrinks to 0.3, rounds into an oval and fades in 0.6 s before leaving the flow, then comes back the same way thanks to @starting-style. transition-behavior: allow-discrete where the browser can animate display, a measured JavaScript fallback elsewhere (Firefox), a 0.15 s fade under reduced motion.

CSSallow-discreteMorph

Updated

This effect is part of Effect.Labs — 840 vanilla effects, some free, some premium. Transitions has 52 effects, including 9 free. Explore the category →

3 usage examples

A "VAT number" field — a wholesaler's account opening form — Morph Transition example 1

① A "VAT number" field — a wholesaler's account opening form

WhenA grocery opens an account with a fruit and vegetable wholesaler: ticking "I am a business" shows the "EU VAT number" field; unticking hides it and the email field moves up.
WhyThe field really goes to display: none: absent, it takes no space and is not read by screen readers, while keeping an animated entrance and exit.
SettingsElement put back in the flow (position: relative, frame with no fixed height); cream field with an amber border; the checkbox is a .morph-tr-btn that draws its tick from aria-expanded; hidden start with aria-expanded="false".
A "line 4 roadworks" banner — a bus network's page — Morph Transition example 2

② A "line 4 roadworks" banner — a bus network's page

WhenOn the Gare stop page, the orange roadworks banner fades out when the works end; the "Traffic info" button brings it back. The next departures move up when it disappears.
WhyRemoved from the flow at the end of the animation, the banner leaves no hole in the page: the departures list takes its place back with no empty gap.
SettingsFull-width banner, 5 px orange left border, brown text on cream; button in the network's green header; same 0.6 s duration.
Shipping cost breakdown — a loose-leaf tea shop's basket — Morph Transition example 3

③ Shipping cost breakdown — a loose-leaf tea shop's basket

WhenThe customer taps "Details" on the shipping line: packaging, carrier and the free-shipping threshold (€6.50 more to spend) slide in under the line, and the total and the "Place order" button move down by as much.
WhyThe block enters and leaves the flow through display: none, with no empty space kept: the basket stays short until something is asked for.
SettingsCream block put back in the basket's flow (position: relative, frame with no fixed height): two cost lines, a gauge and the amount left for free shipping; green pill "Details" button on the "Shipping" line; the total and "Place order" move up when the block goes to display: none.

How it works

The .morph-tr-element block (pink gradient #ec4899 → #f43f5e, 14 px corners) is driven by a button that points to it with aria-controls. To hide it, one class, .morph-tr-display-none, asks at once for display: none, opacity: 0, scale(0.3) and border-radius: 50%. Normally display flips at once and cuts any animation. Here the transition lists it with allow-discrete (transition: all 0.6s …, display 0.6s allow-discrete): the browser keeps the element displayed for the 0.6 s, plays the shrinking and rounding, and only then removes it. Measured in Chromium 151 and WebKit 26.5: at 100 ms, opacity 0.38 to 0.43 and scale 0.55 to 0.60; at 950 ms, display: none.

Coming back raises another problem: an element leaving display: none has no "before" state, so nothing to animate. The @starting-style rule provides one (opacity 0, scale 0.3, 50% corners): on display, it starts from there and returns to its shape on the cubic-bezier(0.34, 1.56, 0.64, 1) bouncy curve — measured at 100 ms: opacity 0.57 to 0.62, scale 0.70 to 0.73, in all three engines. A consequence to know: @starting-style also plays on the page's first display, so the element eases in on load.

Firefox 153 answers yes to CSS.supports('transition-behavior', 'allow-discrete') but does not animate display yet: without a fallback, the element would vanish at once there (measured: display: none and opacity 0 from 150 ms). The script (2.4 KB) checks it on an invisible test element at load — it sets a div to display: none with the transition and reads whether display held. If it did not, it animates through the .morph-tr-hidden class, then sets .morph-tr-gone (display: none) after the 0.6 s; on entry, it removes .morph-tr-gone, forces a calculation, then removes .morph-tr-hidden. Same result measured in Firefox: opacity 0.39 at 100 ms, display: none at 700 ms. A hidden start is declared with aria-expanded="false" on the button: the script sets the hidden-state classes that suit the browser.

Accessibility

  • Truly absent once hidden: with display: none, the element is neither read by screen readers nor reached by the Tab key — which a mere opacity of 0 does not guarantee. That is the point of this effect for a form field or a filter panel.
  • Button state: the button carries aria-controls and aria-expanded, updated on every click (true when the element is shown, measured). For a checkbox that shows a field (scene 1), keep a real button or a real checkbox whose change calls the same toggle.
  • Reduced motion: under prefers-reduced-motion, no shrinking or rounding: a 0.15 s fade, then display: none (measured in all three engines: scale 1 and opacity 0.08 to 0.11 at 100 ms, removed at 350 ms).
  • Contrast: 13.6 px bold white text on #ec4899 → #f43f5e, i.e. 3.5 to 3.7:1, under the 4.5:1 AA threshold. For text that matters, use #be185d → #be123c (6.0 and 6.3:1) or put the content on a light background, as in the scenes.

Browser compatibility

CSS (@starting-style, transition-behavior: allow-discrete, @supports) + 2.4 KB of JavaScript, no dependency. Measured in Chromium 151, WebKit 26.5 (CSS path) and Firefox 153 (JavaScript path): same animated exit followed by display: none, same entrance, same end state, fast double click without a stuck state, two independent elements on the same page. Public markers: @starting-style since Chrome 117, Safari 17.5 and Firefox 129; display transitions since Chrome 117 and Safari 18; Safari 17.4 to 17.6 only knows the allow-discrete value, and the script takes over there.

Chrome 117+✓ Full (CSS path)
Firefox 129+✓ Full (exit animated by the script)
Safari 17.5+✓ Full (CSS path from 18, script before)
Edge 117+✓ Full
Mobile iOS 17.5+✓ Full (CSS path from 18, script before)
Android Chrome 117+✓ Full

In older browsers, the script takes the class path: animated exit then display: none, animated entrance. Only the arrival on page load (carried by @starting-style) is not animated there. Without JavaScript, the button does nothing and the element stays in its starting state.

The code

HTML structure to paste into your page (the CSS + JS come with Premium):

index.html — structure
<div class="morph-tr-stage">
  <div class="morph-tr-wrapper">
    <div class="morph-tr-element" id="morphTrElement">…</div>
  </div>
  <button class="morph-tr-btn" type="button" aria-controls="morphTrElement" aria-expanded="true">…</button>
</div>
Unlock the full code — $9.90/mo incl. tax

The full HTML + CSS + JS of Morph Transition, ready to paste — plus all 840 effects. Or $299 once, lifetime access.

Customize

Options passed to the API or data-* attributes:

Option / propertyDefaultEffect
Exit shape (CSS — .morph-tr-display-none, .morph-tr-hidden and @starting-style) scale(0.3), border-radius: 50%, opacity 0 translateY(-8px) instead of the scale gives a soberer upward exit. Change the three rules together so entrance and exit match.
Duration (CSS — 0.6s; JS — 600 in the fallback) 0.6 s Keep the same value in the CSS (display 0.6s allow-discrete included) and in the script's delay, otherwise Firefox removes the element too early or too late.
Freeing the space (CSS — .morph-tr-wrapper and the element's position: absolute) fixed 200 × 100 px frame: the space stays reserved For the following content to move up, put the element back in the flow (position: relative) and remove the frame's height. Do not set a display with a selector stronger than .morph-tr-element.morph-tr-display-none (inline style, id…): the hidden-state rule must be able to set it to none. The less specific display: flex in the sold CSS does no harm.
Hidden start (HTML — aria-expanded="false" on the button, morph-tr-display-none class on the element) shown on load The class avoids a brief flash before the script runs; the script then sets the right class combination for the browser.
Entrance on load (CSS — @starting-style block) the shown element eases in when the page opens To have it there from the start, add a class to the page after the first display and limit the @starting-style rule to that class.
Bounce (CSS — cubic-bezier(.34, 1.56, .64, 1)) slight overshoot on entrance ease for a serious form; the bounce suits a bubble or a card better.

FAQ

That is how display: none works: during the 0.6 s, the element stays displayed (it shrinks visually, but scale does not change the space it takes), then it is removed and what follows moves up in one go. On entrance it is the reverse: the space opens first, then the element grows. For a gradual slide of the following content, the height has to be animated, as Accordion Fold (fx-0720) does.
Because Firefox 153 answers yes to CSS.supports('transition-behavior', 'allow-discrete') while removing the element without waiting for the end of the transition (measured). The test element checks the real behavior: it sets an invisible div to display: none with the transition and looks whether display still holds. The day Firefox animates display, it will take the CSS path without any code change.
Popover Bloom opens a bubble on top of the page, anchored to a button, without touching the layout. Morph Transition acts in the flow: the element takes its space when it is there and gives it back when it disappears. Both rely on @starting-style for the entrance.