Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Vortex dismiss

New

A close button that swallows what it closes: the card twists into a green vortex on the ×, collapses to a point and bursts.

vortex-dismiss/vortex-dismiss.tsx
Live · interactive

Launch your venture

Raise from your community, guided by markets.

The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.

When to use

  • Closing a promo, an offer or a celebration card where the leaving itself is part of the moment: a launch page, an onboarding reward, a playful product.
  • A dismissal the reader should feel happened, once, on a card that will not come straight back.
  • Any markup that should leave with a flourish: the content is twisted live through an SVG filter, never copied, so a real card with its own buttons can be swallowed.
  • A showcase or landing stage with room around the content, where a 3.35 s effect can fill the stage without hiding anything else.

When not to use

  • Closing dialogs, toasts or banners many times a day. Use a plain close button with a short fade.
  • Deleting or archiving something the reader may want back. Use a quiet removal with an Undo toast.
  • Rows in a list or cards in a dense grid: the whirl fills the whole stage. Use a collapse or a slide out.
  • Dense or busy screens where a burst of light over everything would pull focus from the work. Use a fade.
  • Pages that must stay light on old phones or without WebGL budget to spare. Use a CSS exit animation.

You can add this component with the shadcn CLI or by hand:

Into components/vortex-dismiss of any React project, with no setup:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/vortex-dismiss.json

Either way it needs React 19.2 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

Usage

promo.tsx
import { VortexDismiss } from './vortex-dismiss';export function Promo({ onClose }: { onClose: () => void }) {  return (    <VortexDismiss autoplay={false} restoreAfter={null} onDismissed={onClose}>      <aside className="promo">…</aside>    </VortexDismiss>  );}

Each state below is a live instance, locked so you can study it.

The pieces it is built from, each with its measurements read live.

Press the × and a vortex opens on it. A lime shock ring rolls out, a teal whirl fills the stage, and the card is torn open at the eye, twisted, folded edge on and drawn in until it is gone. The eye swells white, collapses to a point, flashes lime, and a pinwheel of light spreads out and fades. It wraps any content, and the sample card comes back for another go.

  • One clock drives everything: each frame works out every part from the seconds since the press, so nothing drifts and a tab that was hidden picks up where it should be. The whole effect lasts 3.35 s.
  • The content is never copied, so any markup can be swallowed. It twists through an SVG filter: three feDisplacementMap passes read one picture of a swirl field, drawn once per press, and each pass turns it a little further, like steps of a flow. A radial mask eats it from the eye outwards.
  • Over the swirl, a CSS transform about the eye turns it anticlockwise, folds it edge on about the line from the eye through its middle (rotate3d under a perspective) and shrinks it, while it pales and blurs, so it leaves as a bright sliver curling into the eye.
  • The light is one fragment shader on two canvases: under the content, the teal whirl with its grey shade and dark throat; over it, the shock ring, the lime core turning at 4 rad/s, the torn edge, the sparks, a lens flare (a hairline across the stage, a fan of rays and long beams), the white bloom, the flash and the burst. Each layer is laid over the last with its own alpha, never added, so the colours read on a light page as on a dark one.
  • The beats: shock ring 0–0.4 s, core from 0.1 s, the card folds away from 0.5 s and is gone by 1.4 s, the bloom swells from 1.3 s and collapses at 1.8 s, the flash at 1.86 s, then the burst opens to its full size and fades by 3.35 s.
  • The effect scales with the stage: it was drawn for an eye 541 px from the stage’s farthest corner and grows or shrinks from there. Without WebGL, a CSS whirl of conic and radial gradients stands in.
  • Uncontrolled, the content comes back restoreAfter ms after the burst, with a 160 ms fade out of a blur; controlled, it leaves and returns as `open` says. While on screen and untouched, a demo pointer comes in, presses the ×, and lets it play.
PropTypeDefaultDescription
childrenReactNode | ((dismiss: (from?: HTMLElement | { x: number; y: number }) => void) => ReactNode)—What the vortex swallows; without it, a sample promo card. A function gets dismiss, to start the vortex from an element of its own or a point in the content’s px.
openboolean—Controlled: the content shows while true; turning false plays the vortex.
onDismiss() => void—Called on the press, as the vortex starts.
onDismissed() => void—Called once the burst has faded.
restoreAfternumber | null800Uncontrolled: ms after the burst before the content comes back; null keeps it gone.
closeButtonbooleantrueDraws the round × over the content’s top right corner.
closeLabelstring'Dismiss'The close button’s accessible name.
closeInsetnumber14.5The ×’s distance from the content’s top and right edges, in px.
palette{ glow?: string; shade?: string; spark?: string; burst?: string }—The effect’s colours, as hex: the whirl, its shade, the lime of the core and sparks, the burst’s rim.
autoplaybooleantrueWhile on screen and untouched, a demo pointer presses the × now and then (uncontrolled, with restoreAfter set).
colorScheme'light' | 'dark' | 'system'—Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
frozenAtnumber—Holds the vortex still this many seconds after a press (0 to 3.35), as if paused there: a still for docs and previews. Nothing finishes and the demo stays off.
classNamestring—Extra class for the root.

The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

vortex-dismiss.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useCallback,  useEffect,  useEffectEvent,  useId,  useMemo,  useRef,  useState,  type CSSProperties,  type KeyboardEvent as ReactKeyboardEvent,  type ReactNode,} from 'react';import { DemoPointer, runDemo } from './demo-pointer';import { PromoCard } from './promo-card';import { useAutoplay } from './use-autoplay';import {  createField,  DEFAULT_PALETTE,  parseHex,  swirlMap,
vortex-dismiss.module.css
/* Vortex dismiss (vortex-dismiss.tsx): a pale stage with the content in its middle, the × over   the content's top right corner, and the vortex's two canvases, one under the content and one   over it. At night the stage and the card go near-black; the vortex keeps its colours.   Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes   the color-scheme it inherits from the page (light unless the page says otherwise), or the one   its colorScheme prop sets (data-color-scheme).   Stacking: the stage isolates its layers. Under the content, the whirl; then the content; then   the light; then the ×, which stays crisp over the vortex until it collapses; then the demo   pointer. */.root {  /* The stage. */  --page: #f3f5f2;  /* The card, its hairline edge and its lift. */  --card: #fff;  --card-edge: rgb(0 0 0 / 0.085);  --card-shadow: rgb(0 0 0 / 0.05);  /* Title and copy. */  --ink: #212323;  --ink-2: #646464;  /* The call to action. */  --button: #fff;
vortex-field.ts
// Copyright (c) 2026 Bruno Delorence. MIT License./* * The light of the vortex, drawn by one fragment shader on two canvases: the back one, under the * content, holds the wide teal whirl and its grey shade; the front one, over the content, holds * everything that glows (the shock ring, the lime core, the torn edge, the sparks, the lens * flare, the white bloom, the flash and the burst). Each pixel works out where it sits from the * vortex's eye in "design px" (the size the effect was drawn at, scaled to the stage), so the * whole effect grows with the stage around it. Every layer is laid over the last with its own * alpha, never added, so the lime and the teal read as colours on a light page as on a dark one. * * Also here: the displacement map the content is twisted with, a picture of a swirl field that * an SVG feDisplacementMap reads (the content itself is never copied into a texture, so any * markup can be swallowed). *//** The effect's colours, as 0–255 RGB. */export type Palette = {  /** The wide whirl. */  glow: [number, number, number];  /** The darker shade that winds through it. */  shade: [number, number, number];  /** The core, the sparks, the shock ring and the flash. */  spark: [number, number, number];
promo-card.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useId } from 'react';import styles from './vortex-dismiss.module.css';/* * The sample content the vortex swallows when nothing else is given: a promo card with a glossy * green creature on top, a title, a line of copy and a call to action. The art is one SVG of * gradients only (no filters, no raster), so it stays sharp at any size and costs nothing to * redraw while the card twists. *//** The creature: a soft four-armed blob, two arched eyes, a leaf scarf, a chain and a coin. */function Creature() {  // Gradient ids are per instance: two cards on a page must not share them.  const id = useId().replace(/[^a-zA-Z0-9]/g, '');  const ref = (name: string) => `url(#${id}${name})`;  return (    <svg      className={styles.creature}      viewBox="0 0 256 112"      preserveAspectRatio="xMidYMid slice"
demo-pointer.tsx
'use client';import type { CSSProperties, Ref } from 'react';/* * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait, or * glide the pointer somewhere. Stopping the demo (the reader takes over, or it scrolls away) ends * the step in progress at once: its promise rejects, which ends the script right there, and the * pointer fades out. * * Each glide is a curved flight played by the Web Animations API, so nothing runs frame by frame. * The points of the flight are handed to whoever wants to move along with it (the eye of the * card, should anything want to move along with it). The arrow and the hand are HTML boxes * that swap by their visibility: a change of style inside an SVG would lay the SVG out again. */export type Point = { x: number; y: number };/** A glide's path and timing, for an animation that plays along with it. */export type Flight = { points: Point[]; timing: KeyframeAnimationOptions };export type Demo = {  /** Resolves after `ms`. */  wait: (ms: number) => Promise<void>;
use-autoplay.ts
'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) {  const ref = useRef<T>(null);  const [visible, setVisible] = useState(false);  const [reduced, setReduced] = useState(true);  const [taken, setTaken] = useState(false);  const timer = useRef(0);  useEffect(() => {    const element = ref.current;    if (!element) return;    let onScreen = false;    const update = () => setVisible(onScreen && !document.hidden);    const observer = new IntersectionObserver(      ([entry]) => {        onScreen = entry.isIntersecting;
  • The × is a real button named by closeLabel (“Dismiss”), with a visible focus ring; Escape inside the content dismisses too.
  • A polite live region says “Dismissed”. While the content is leaving and once it is gone, it is inert and hidden from screen readers, and focus moves to the stage instead of being lost on the page.
  • The canvases, the art and the demo pointer are decorative (aria-hidden). Any real input hands control to the reader and stops the demo.
  • Under reduced motion there is no vortex: the content fades out in 0.22 s, and the demo never plays.