Launch your venture
Raise from your community, guided by markets.
Choose your reading theme.
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.tsxRaise 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
When not to use
You can add this component with the shadcn CLI or by hand:
Into components/vortex-dismiss of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/vortex-dismiss.jsonCopy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
As shown under Usage, below.
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.
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.
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | ((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. |
open | boolean | — | 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. |
restoreAfter | number | null | 800 | Uncontrolled: ms after the burst before the content comes back; null keeps it gone. |
closeButton | boolean | true | Draws the round × over the content’s top right corner. |
closeLabel | string | 'Dismiss' | The close button’s accessible name. |
closeInset | number | 14.5 | The ×’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. |
autoplay | boolean | true | While 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. |
frozenAt | number | — | 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. |
className | string | — | 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.
// 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 (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;// 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];// 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"'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 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;