# Insight card

> A feature card where data sources stream through a funnel of light into a scanner and come out as folders of findings.

A feature card for a data product. Round badges of sources glide in from the left through a funnel of light and dotted particles, into a glowing scanner line. Folders of findings step out of it on the right, one every three sources. The loop runs only while the card is on screen, slows under the pointer, and can be paused.

A React component by Bruno Delorence: https://brunodelorence.com/components/insight-card. MIT License, Copyright (c) 2026 Bruno Delorence.

Category: Motion.

## Anatomy

1. **Card**: The card, a step lighter than its stage. Every size in it is a multiple of its width over 538, up to 540 px, so the picture keeps its proportions.
2. **Funnel**: A static curve of light narrowing to the scanner, with dotted particles drifting through it at the sources’ speed.
3. **Source badge**: A round badge with an app’s glyph. One reaches the scanner every second, clipped at its line.
4. **Scanner**: A bright cyan line with a glow before and a softer one after; what passes it is hidden.
5. **Folder**: A finding, named by `outputs`. One steps out from under a band of frost every third source, eased.
6. **Pause button**: Glass circle that pauses and plays the flow; its pressed state shows Play. Under reduced motion the card starts paused.
7. **Title**: One line, bold, cut with an ellipsis when it runs long.
8. **Line**: One muted line under the title; two when its stage is under 480 px wide.

## When to use

- A feature grid or landing page, where one card has to say “raw data in, insights out” at a glance.
- Introducing an analysis, summary or reporting feature, with its three kinds of output named on the folders.
- A hero or empty state that needs motion to feel alive, but must stay calm: it slows under the pointer and stops off screen.
- Pages that switch between light and dark: the card follows the page, or the `colorScheme` you give it.

## When not to use

- Showing real numbers or results. The picture is an illustration; use a chart or a stat card.
- Many cards side by side all moving. Keep one animated and give the rest `autoplay={false}`, or use still pictures.
- Long titles or lines: they are cut to one line (two when narrow) with an ellipsis. Use a plain text card.
- An action the reader must take. The card has no button besides Pause; use a call to action.
- Showing a real pipeline’s progress. Use a progress bar or a step list that reflects real work.

## Installation

Install it with the shadcn CLI, into components/insight-card of any React project:

```sh
npx shadcn@latest add https://brunodelorence.com/r/insight-card.json
```

Or copy all the source files below into one folder of your project. Either way it needs React 19 and a bundler with CSS Modules (Next.js, Vite).

It has a dark colour scheme. Its colours are CSS light-dark() pairs, kept inside @supports so that builds which rewrite light-dark() for older browsers leave them alone: 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

```tsx
import { InsightCard } from './insight-card';

export function Features() {
  return (
    <InsightCard
      autoplay={false}
      title="Analyze → Summarize → Act"
      text="Turn raw data into clear insights and actionable next steps."
      outputs={['Customer Feedback', 'Revenue Summary', 'Post-Sales Insight']}
    />
  );
}
```

## States

- **Playing**: The flow runs while on screen in a visible tab; this instance moves. Hovering slows it to a third.
- **Paused**: `autoplay={false}`, the reader’s Pause, or reduced motion: a still frame, and the button shows Play.
- **Light**: `colorScheme="light"`: light whatever the page says. Without the prop the card follows the page.
- **Dark**: `colorScheme="dark"`: the stage, card and words darken; the picture stays the same.
- **Narrow**: A stage under 480 px wide: the type grows for its size, the band under the picture is taller and the line may take two lines.
- **Long words**: A title or line too long for the card is cut with an ellipsis; folder names wrap inside the folder.

## How it works

- The funnel, the blue beyond the scanner and its soft streaks of light are one static SVG that never changes.
- The moving rows use the Web Animations API, each shifted by its transform alone. The sources glide steadily, one a second, and the particles drift with them; the folders step on by one, eased, every third source. Each row holds two rounds of its drawings and loops by exactly one round, so the loop has no seam.
- A haze of white light at the funnel’s wide end swells as every third source comes in, then drifts on and fades. It is a gradient box moved by transform and opacity, clipped to the funnel’s shape.
- The scanner hides what passes it. The sources’ row is clipped at the scanner’s line, the folders come out from under a band of frost just past it, and the particles are clipped to the funnel by a polygon computed from its two curves.
- The loop plays only while the card is on screen in a visible tab. Hovering the card eases its speed to about a third. A button pauses it, and under reduced motion it starts as a still frame.
- The stage, the card and its words are light-dark() tokens, so they follow the page’s color-scheme, or the colorScheme prop; the picture (funnel, haze, badges, folders, scanner) is content and stays as it is, and so does the pause button’s glass, which sits on it.

## API reference

- **title** (`string`, default `'Analyze → Summarize → Act'`): The card’s title.
- **text** (`string`, default `'Turn raw data into clear insights and actionable next steps.'`): The line under the title.
- **outputs** (`readonly [string, string, string]`, default `['Customer Feedback', 'Revenue Summary', 'Post-Sales Insight']`): The three folders that come out of the scanner, in turn.
- **onPausedChange** (`(paused: boolean) => void`): Called when the reader pauses (true) or plays (false) the flow with its button.
- **autoplay** (`boolean`, default `true`): Starts the flow on its own when the card comes on screen. When false, it waits, still, for Play.
- **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.
- **className** (`string`): Extra class for the root, a stage of its own.

## Accessibility

- The picture is one image whose label describes the flow and names the folders; all its drawings are hidden from screen readers.
- Pause animation is a real toggle button with aria-pressed and a visible focus ring. Pausing and playing are announced in a polite live region.
- Under reduced motion the card starts as a still frame, and plays only if the reader presses the button.
- The loop stops off screen and in hidden tabs.

## Source

### insight-card/insight-card.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import { useEffect, useId, useRef, useState, type CSSProperties, type PointerEvent } from 'react';
import { Backdrop, FolderDrawing, GLYPHS, SCANNER, funnelPolygon } from './drawings';
import styles from './insight-card.module.css';

/*
 * Insight card: a feature card whose picture shows raw data turned into insights. Round badges of
 * sources stream in from the left through a funnel of light, into a glowing scanner; folders of
 * findings slide out of it on the right.
 *
 * - Every size is a multiple of one unit, the card's width over 538, so the picture keeps its
 *   proportions at any width. The funnel, its glow and the blue beyond are one static SVG.
 * - Three rows move, each a row of fixed drawings shifted by its transform with the Web
 *   Animations API: the sources glide on steadily, one a second; the dotted particles in the
 *   funnel drift with them; the folders step on by one, eased, every third source. Each row holds
 *   two rounds of its drawings and loops by exactly one round, so the loop has no seam.
 * - The scanner hides what passes it: the sources' row is clipped at its line, and the folders'
 *   row is clipped there too; a band of frost just past the line whitens them as they come out.
 * - It plays only while on screen in a visible tab. Hovering the card slows it to a third, the
 *   speed eased in and out. A button pauses it; under reduced motion it starts still, as a frame.
 * - Light or dark: the stage, the card and its words are light-dark() tokens in the stylesheet, so
 *   it follows the page's color-scheme, or the one the colorScheme prop sets. The picture is
 *   content: the same at night.
 */

/** The distance between two sources, and how many there are on their row: two rounds of five. */
const SOURCE_PITCH = 75;
const SOURCES = 10;
/** Where the first source's centre is when the loop starts, across the picture. */
const SOURCE_START = 10.5;
/** The distance between two folders, and how many on their row: two rounds of three. */
const FOLDER_PITCH = 166;
const FOLDERS = 6;
/** Where the folder just coming out of the scanner rests: its left edge, across the picture. */
const FOLDER_REST = 189;
/** One source reaches the scanner every PERIOD / 3 ms; a folder steps out every PERIOD ms. */
const PERIOD = 3000;
/** How long a folder's step takes, and its easing. */
const STEP = 1900;
const STEP_EASING = 'cubic-bezier(0.3, 0.75, 0.25, 1)';
/** The particles' grid, and the width of their row (the funnel's, plus one cell to loop by). */
const DOT_GAP = 11;
const DOT_ROW = SCANNER + 2 * DOT_GAP;
/** How fast the flow runs under a hovering pointer. */
const SLOW = 0.35;

/**
 * The rows' places and sizes, for the stylesheet, in units. Each row starts one round behind, so
 * that the loop's start looks like its end.
 */
const ROWS = {
  '--source-pitch': SOURCE_PITCH,
  '--sources-left': SOURCE_START - 32 - 5 * SOURCE_PITCH,
  '--sources-width': SOURCES * SOURCE_PITCH,
  '--folder-pitch': FOLDER_PITCH,
  '--folders-left': FOLDER_REST - 3 * FOLDER_PITCH - SCANNER,
  '--folders-width': FOLDERS * FOLDER_PITCH,
  '--scanner': SCANNER,
  '--dot-gap': DOT_GAP,
  '--dot-row': DOT_ROW,
} as CSSProperties;

/** Where the particles may go: inside the funnel, clear of its edges. */
const FUNNEL = funnelPolygon(7);
/** The whole inside of the funnel, which the haze fills. */
const FUNNEL_EDGE = funnelPolygon(0);

const DEFAULT_OUTPUTS = ['Customer Feedback', 'Revenue Summary', 'Post-Sales Insight'] as const;

const reducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;

export function InsightCard({
  className = '',
  autoplay = true,
  title = 'Analyze → Summarize → Act',
  text = 'Turn raw data into clear insights and actionable next steps.',
  outputs = DEFAULT_OUTPUTS,
  colorScheme,
  onPausedChange,
}: {
  /** Extra class for the root, a stage of its own. */
  className?: string;
  /** Starts the flow on its own when the card comes on screen; false: it waits, still, for Play. */
  autoplay?: boolean;
  /** The card's title. */
  title?: string;
  /** The line under the title. */
  text?: string;
  /** The three folders that come out of the scanner, in turn. */
  outputs?: readonly [string, string, string];
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  /** Called when the reader pauses (true) or plays (false) the flow with its button. */
  onPausedChange?: (paused: boolean) => void;
}) {
  const ids = `ic${useId().replace(/[^a-zA-Z0-9]/g, '')}`;
  const root = useRef<HTMLDivElement>(null);
  const sources = useRef<HTMLDivElement>(null);
  const particles = useRef<HTMLDivElement>(null);
  const folders = useRef<HTMLDivElement>(null);
  const haze = useRef<HTMLSpanElement>(null);
  const bleed = useRef<HTMLSpanElement>(null);
  const flow = useRef<Animation[]>([]);
  const rate = useRef({ value: 1, frame: 0 });
  const [paused, setPaused] = useState(!autoplay);
  const [onScreen, setOnScreen] = useState(false);
  const [message, setMessage] = useState('');

  // On screen in a visible tab; and, under reduced motion, a still frame to start with.
  useEffect(() => {
    const element = root.current;
    if (!element) return;
    if (reducedMotion()) setPaused(true);
    let visible = false;
    const update = () => setOnScreen(visible && !document.hidden);
    const observer = new IntersectionObserver(([entry]) => {
      visible = entry.isIntersecting;
      update();
    });
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
    };
  }, []);

  // The flow runs while on screen and not paused; its animations are made the first time.
  const running = onScreen && !paused;
  useEffect(() => {
    if (!running) {
      flow.current.forEach((animation) => animation.pause());
      return;
    }
    if (!flow.current.length) {
      const row = sources.current;
      const dots = particles.current;
      const out = folders.current;
      const light = haze.current;
      const glow = bleed.current;
      if (!row || !dots || !out || !light || !glow) return;
      const perSource = PERIOD / 3;
      // The sources: on by one round of five, steadily.
      const glide = row.animate([{ transform: 'none' }, { transform: 'translateX(50%)' }], {
        duration: perSource * 5,
        iterations: Infinity,
      });
      // The particles: on by one cell of their grid, at the sources' speed.
      const drift = dots.animate(
        [{ transform: 'none' }, { transform: `translateX(${(DOT_GAP / DOT_ROW) * 100}%)` }],
        { duration: (perSource * DOT_GAP) / SOURCE_PITCH, iterations: Infinity },
      );
      // The folders: three eased steps of one folder, each followed by a rest.
      const move = STEP / (PERIOD * 3);
      const steps: Keyframe[] = [];
      for (let k = 0; k < 3; k += 1) {
        steps.push({
          offset: k / 3,
          transform: `translateX(${(k * 100) / 6}%)`,
          easing: STEP_EASING,
        });
        steps.push({ offset: k / 3 + move, transform: `translateX(${((k + 1) * 100) / 6}%)` });
      }
      steps.push({ offset: 1, transform: 'translateX(50%)' });
      const step = out.animate(steps, { duration: PERIOD * 3, iterations: Infinity });
      // The first step comes a moment in, as a source reaches the scanner.
      step.currentTime = PERIOD * 3 - 700;
      // A haze of light at the funnel's wide end, as every third source comes in: it drifts on
      // and fades, and comes back for the next. Its glow past the funnel's edges goes with it.
      // `width` is the element's, in units: the drift is set in units, the transform in shares.
      const pulse = (element: HTMLElement, width: number) => {
        const by = (units: number) => `translateX(${((units / width) * 100).toFixed(2)}%)`;
        return element.animate(
          [
            { offset: 0, opacity: 1, transform: 'none', easing: 'ease-in-out' },
            { offset: 0.22, opacity: 0.5, transform: by(28), easing: 'ease-out' },
            { offset: 0.42, opacity: 0, transform: by(56) },
            { offset: 0.88, opacity: 0, transform: 'none', easing: 'ease-in' },
            { offset: 1, opacity: 1, transform: 'none' },
          ],
          { duration: PERIOD, iterations: Infinity },
        );
      };
      flow.current = [glide, drift, step, pulse(light, 180), pulse(glow, 124)];
      flow.current.forEach((animation) => {
        animation.playbackRate = rate.current.value;
      });
    }
    flow.current.forEach((animation) => animation.play());
  }, [running]);

  useEffect(
    () => () => {
      cancelAnimationFrame(rate.current.frame);
      flow.current.forEach((animation) => animation.cancel());
      flow.current = [];
    },
    [],
  );

  /** Eases the flow's speed to `target` over a moment. */
  const easeRate = (target: number) => {
    const state = rate.current;
    cancelAnimationFrame(state.frame);
    const from = state.value;
    const start = performance.now();
    const tick = (now: number) => {
      const t = Math.min(1, Math.max(0, (now - start) / 450));
      state.value = from + (target - from) * (1 - (1 - t) ** 3);
      flow.current.forEach((animation) => {
        animation.playbackRate = state.value;
      });
      state.frame = t < 1 ? requestAnimationFrame(tick) : 0;
    };
    state.frame = requestAnimationFrame(tick);
  };

  const onHover = (event: PointerEvent<HTMLElement>, over: boolean) => {
    if (event.pointerType === 'mouse') easeRate(over ? SLOW : 1);
  };

  const toggle = () => {
    const next = !paused;
    setPaused(next);
    setMessage(next ? 'Animation paused.' : 'Animation playing.');
    onPausedChange?.(next);
  };

  return (
    <div ref={root} className={`${styles.stage} ${className}`} data-color-scheme={colorScheme}>
      <div className={styles.scene}>
        <article
          className={styles.card}
          data-part="card"
          onPointerEnter={(event) => onHover(event, true)}
          onPointerLeave={(event) => onHover(event, false)}
        >
          <div className={styles.picture} data-part="picture">
            <div
              className={styles.media}
              style={ROWS}
              role="img"
              aria-label={`Sources stream into a scanner and come out as folders: ${outputs.join(', ')}.`}
            >
              <Backdrop className={styles.backdrop} ids={ids} />
              <div className={styles.inflow}>
                <span ref={bleed} className={styles.bleed} />
                <div className={styles.hazeBox} style={{ clipPath: FUNNEL_EDGE }}>
                  <span ref={haze} className={styles.haze} />
                </div>
                <div className={styles.funnel} style={{ clipPath: FUNNEL }} data-part="funnel">
                  <div ref={particles} className={styles.particles} />
                </div>
                <div ref={sources} className={styles.sources}>
                  {Array.from({ length: SOURCES }, (_, k) => (
                    <span
                      key={k}
                      className={styles.source}
                      style={{ '--k': k } as CSSProperties}
                      data-part="source"
                    >
                      {GLYPHS[k % GLYPHS.length]}
                    </span>
                  ))}
                </div>
              </div>
              <div className={styles.outflow}>
                <div ref={folders} className={styles.folders}>
                  {Array.from({ length: FOLDERS }, (_, j) => (
                    <div
                      key={j}
                      className={styles.folder}
                      style={{ '--j': j } as CSSProperties}
                      data-part="folder"
                    >
                      <FolderDrawing ids={ids} />
                      <span className={styles.label} data-part="label">
                        {outputs[j % outputs.length]}
                      </span>
                    </div>
                  ))}
                </div>
                <span className={styles.frost} />
              </div>
              <span className={styles.glowBefore} />
              <span className={styles.glowAfter} />
              <span className={styles.scanner} data-part="scanner" />
            </div>
            <button
              type="button"
              className={styles.pause}
              data-part="pause"
              aria-label="Pause animation"
              aria-pressed={paused}
              title={paused ? 'Play' : 'Pause'}
              onClick={toggle}
            >
              <span className={styles.glyphPause}>
                <svg viewBox="0 0 16 16" aria-hidden="true">
                  <path d="M5 3.5v9M11 3.5v9" />
                </svg>
              </span>
              <span className={styles.glyphPlay}>
                <svg viewBox="0 0 16 16" aria-hidden="true">
                  <path d="M5.5 3.6v8.8a.6.6 0 0 0 .9.5l6.6-4.4a.6.6 0 0 0 0-1L6.4 3.1a.6.6 0 0 0-.9.5z" />
                </svg>
              </span>
            </button>
          </div>
          <h3 className={styles.title} data-part="title">
            {title}
          </h3>
          <p className={styles.text} data-part="text">
            {text}
          </p>
        </article>
        <p className={styles.hidden} aria-live="polite">
          {message}
        </p>
      </div>
    </div>
  );
}
```

### insight-card/insight-card.module.css

```css
/* Insight card (insight-card.tsx): a card on a stage of its own, its picture above its title.

   Every size is a multiple of --u, the card's width over 538, so the card scales as one drawing;
   the stage is a size container and the card's width follows it, up to 540px. The scene is one
   layout boundary of a size computed from that width, so nothing inside it lays out the page.

   Motion. Three rows move by their transform alone, each on a layer of its own: the sources, the
   particles and the folders. Their clips and the mask that fades the folders in are fixed boxes
   around them; the scanner and its glows are fixed gradients over them.

   Colours. The interface's are the tokens below, each light-dark(light, dark): the card 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). The picture (drawings.tsx, and the haze, particles,
   badges, frost and scanner here) is content: the same in both. --text and the rows' places, set
   on the scene and the picture, are sizes, so no token below takes those names. */

.stage {
  /* The stage, and the card on it: white on white by day, the card a step lighter at night. */
  --surface: #fff;
  --surface-2: #fff;
  /* The title, the line under it, and the stage's own text. */
  --ink: #383838;
  --ink-muted: #6b6b6b;
  --ink-soft: #484848;
  /* Hairlines: the stage's edge, and the card's. */
  --line: rgb(0 0 0 / 0.06);
  --edge: rgb(0 0 0 / 0.05);
  /* The card's lift: a shade right under it, then a soft drop, deeper at night. */
  --shadow-near: rgb(16 24 40 / 0.04);
  --shadow: rgb(16 24 40 / 0.08);
  /* The same in both, as they sit on the picture: its blue (under the drawing until it paints),
     and the pause button, white glass with a white glyph and focus ring. */
  --accent: #3a8cf6;
  --accent-ink: #fff;
  --glass: rgb(255 255 255 / 0.16);
  --glass-hover: rgb(255 255 255 / 0.3);
  --glass-edge: rgb(255 255 255 / 0.4);
  position: relative;
  container-type: inline-size;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background: var(--surface);
  box-shadow: inset 0 0 0 1px var(--line);
  color: var(--ink-soft);
  font-family:
    Jakarta,
    'Plus Jakarta Sans',
    Inter,
    -apple-system,
    BlinkMacSystemFont,
    'Segoe UI',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .stage {
    --surface: light-dark(#fff, #111113);
    --surface-2: light-dark(#fff, #1c1c1f);
    --ink: light-dark(#383838, #f0f0f2);
    --ink-muted: light-dark(#6b6b6b, #a1a1a7);
    --ink-soft: light-dark(#484848, #d6d6da);
    --line: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.06));
    --edge: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.08));
    --shadow-near: light-dark(rgb(16 24 40 / 0.04), rgb(0 0 0 / 0.3));
    --shadow: light-dark(rgb(16 24 40 / 0.08), rgb(0 0 0 / 0.5));
  }
}
/* The colorScheme prop. Without it, nothing is set: color-scheme is inherited from the page. */
.stage[data-color-scheme='light'] {
  color-scheme: light;
}
.stage[data-color-scheme='dark'] {
  color-scheme: dark;
}
.stage[data-color-scheme='system'] {
  color-scheme: light dark;
}
.stage *,
.stage *::before,
.stage *::after {
  box-sizing: border-box;
}

.scene {
  --pad: clamp(16px, 7cqi, 64px);
  --w: min(100cqi - 2 * var(--pad), 540px);
  --u: calc(var(--w) / 538);
  /* The text under the picture. */
  --text: calc(137 * var(--u));
  position: relative;
  display: grid;
  place-items: center;
  /* A little less room above and below than at the sides, so the stage stays short. */
  height: calc(290 * var(--u) + var(--text) + 1.8 * var(--pad));
  contain: size layout;
}

.card {
  width: var(--w);
  height: calc(290 * var(--u) + var(--text));
  padding: calc(13 * var(--u)) calc(13 * var(--u)) 0;
  border-radius: calc(14 * var(--u));
  background: var(--surface-2);
  box-shadow:
    0 0 0 1px var(--edge),
    0 calc(2 * var(--u)) calc(4 * var(--u)) var(--shadow-near),
    0 calc(12 * var(--u)) calc(32 * var(--u)) var(--shadow);
}

/* The picture, and its pause button. */
.picture {
  position: relative;
  height: calc(277 * var(--u));
}
.media {
  position: absolute;
  inset: 0;
  overflow: hidden;
  isolation: isolate;
  border-radius: calc(6 * var(--u));
  background: var(--accent);
}
.backdrop {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
}

/* Before the scanner: the funnel's particles, then the sources, clipped at the scanner's line. */
.inflow {
  position: absolute;
  top: 0;
  left: 0;
  width: calc(var(--scanner) * var(--u));
  height: 100%;
  overflow: hidden;
}
/* The haze at the funnel's wide end: white, fading towards the scanner, and a fainter glow of it
   past the funnel's edges. Still, they show lit. */
.hazeBox {
  position: absolute;
  inset: 0;
}
.bleed {
  position: absolute;
  top: 0;
  bottom: 0;
  left: calc(-60 * var(--u));
  width: calc(124 * var(--u));
  background: linear-gradient(
    to right,
    rgb(240 243 255 / 0.6) 48.4%,
    rgb(240 243 255 / 0.32) 63.9%,
    rgb(240 243 255 / 0.1) 80.4%,
    rgb(240 243 255 / 0)
  );
}
.haze {
  position: absolute;
  top: 0;
  bottom: 0;
  /* Wider than the funnel's mouth on the left, so that it drifts on without an edge. */
  left: calc(-60 * var(--u));
  width: calc(180 * var(--u));
  background: linear-gradient(
    to right,
    rgb(240 243 255 / 0.9) 33.3%,
    rgb(240 243 255 / 0.8) 47.3%,
    rgb(240 243 255 / 0.64) 64%,
    rgb(240 243 255 / 0.36) 78%,
    rgb(240 243 255 / 0.06) 89.3%,
    rgb(240 243 255 / 0)
  );
}
.funnel {
  position: absolute;
  inset: 0;
  -webkit-mask-image: linear-gradient(to right, transparent 14%, rgb(0 0 0 / 0.4) 44%, #000 80%);
  mask-image: linear-gradient(to right, transparent 14%, rgb(0 0 0 / 0.4) 44%, #000 80%);
}
.particles {
  position: absolute;
  top: 0;
  left: calc(-2 * var(--dot-gap) * var(--u));
  width: calc(var(--dot-row) * var(--u));
  height: 100%;
  background-image:
    radial-gradient(
      circle,
      rgb(255 255 255 / 0.95) 0 calc(0.85 * var(--u)),
      transparent calc(1.25 * var(--u))
    ),
    radial-gradient(
      circle,
      rgb(255 255 255 / 0.75) 0 calc(0.75 * var(--u)),
      transparent calc(1.15 * var(--u))
    );
  background-position:
    0 0,
    calc(var(--dot-gap) * var(--u) / 2) calc(5 * var(--u));
  background-size: calc(var(--dot-gap) * var(--u)) calc(10 * var(--u));
}
.sources {
  position: absolute;
  top: calc(115.5 * var(--u));
  left: calc(var(--sources-left) * var(--u));
  width: calc(var(--sources-width) * var(--u));
  height: calc(64 * var(--u));
}
.source {
  position: absolute;
  top: 0;
  left: calc(var(--k) * var(--source-pitch) * var(--u));
  display: grid;
  place-items: center;
  width: calc(64 * var(--u));
  height: calc(64 * var(--u));
  border-radius: 50%;
  background: radial-gradient(circle at 50% 38%, #fff 0, #f5fbff 55%, #def0ff 100%);
  box-shadow:
    0 0 0 calc(4 * var(--u)) rgb(255 255 255 / 0.22),
    0 calc(6 * var(--u)) calc(14 * var(--u)) rgb(16 92 200 / 0.18);
}
.source svg {
  display: block;
  width: calc(48 * var(--u));
  height: calc(48 * var(--u));
}

/* After the scanner: the folders, coming out of it from under a band of frost. */
.outflow {
  position: absolute;
  top: 0;
  right: 0;
  left: calc(var(--scanner) * var(--u));
  height: 100%;
  overflow: hidden;
  -webkit-mask-image: linear-gradient(to right, rgb(0 0 0 / 0.8), #000 calc(30 * var(--u)));
  mask-image: linear-gradient(to right, rgb(0 0 0 / 0.8), #000 calc(30 * var(--u)));
}
.frost {
  position: absolute;
  top: calc(89 * var(--u));
  left: 0;
  width: calc(46 * var(--u));
  height: calc(114 * var(--u));
  background: linear-gradient(
    to right,
    rgb(232 246 255 / 0.85),
    rgb(232 246 255 / 0.5) 38%,
    rgb(232 246 255 / 0)
  );
  pointer-events: none;
}
.folders {
  position: absolute;
  top: calc(72 * var(--u));
  left: calc(var(--folders-left) * var(--u));
  width: calc(var(--folders-width) * var(--u));
  /* Only as tall as a folder and its shadow: the layer it moves on stays small. */
  height: calc(152 * var(--u));
}
.folder {
  position: absolute;
  top: calc(17 * var(--u));
  left: calc(var(--j) * var(--folder-pitch) * var(--u));
  width: calc(144 * var(--u));
  height: calc(114 * var(--u));
  filter: drop-shadow(0 calc(6 * var(--u)) calc(9 * var(--u)) rgb(20 56 150 / 0.28));
}
.folder svg {
  display: block;
  width: 100%;
  height: 100%;
}
.label {
  position: absolute;
  top: calc(39 * var(--u));
  left: calc(14 * var(--u));
  width: calc(66 * var(--u));
  color: #8c8c8c;
  font-family:
    Inter,
    -apple-system,
    BlinkMacSystemFont,
    'Segoe UI',
    sans-serif;
  font-size: calc(10 * var(--u));
  line-height: calc(12.4 * var(--u));
}

/* The scanner: a bright line, a cyan glow before it and a softer one after. */
.glowBefore,
.glowAfter,
.scanner {
  position: absolute;
  top: 0;
  bottom: 0;
  pointer-events: none;
}
.glowBefore {
  left: calc((var(--scanner) - 30) * var(--u));
  width: calc(30 * var(--u));
  background: linear-gradient(
    to right,
    rgb(16 214 255 / 0),
    rgb(16 214 255 / 0.5) 60%,
    rgb(24 232 255 / 0.85)
  );
}
.glowAfter {
  left: calc(var(--scanner) * var(--u));
  width: calc(50 * var(--u));
  background: linear-gradient(
    to right,
    rgb(46 200 255 / 0.7),
    rgb(56 196 255 / 0.42) 14%,
    rgb(66 190 255 / 0.24) 34%,
    rgb(76 184 255 / 0.1) 62%,
    rgb(76 184 255 / 0)
  );
}
.scanner {
  left: calc((var(--scanner) - 2) * var(--u));
  width: calc(4 * var(--u));
  background: linear-gradient(
    to right,
    rgb(80 240 255 / 0),
    #5ff3ff 30%,
    #d9ffff 50%,
    #5ff3ff 70%,
    rgb(80 240 255 / 0)
  );
}

.pause {
  position: absolute;
  right: calc(9 * var(--u));
  bottom: calc(9 * var(--u));
  display: grid;
  place-items: center;
  width: max(24px, calc(26 * var(--u)));
  height: max(24px, calc(26 * var(--u)));
  margin: 0;
  padding: 0;
  border: 1px solid var(--glass-edge);
  border-radius: 50%;
  background: var(--glass);
  color: var(--accent-ink);
  cursor: pointer;
  transition: background-color 0.2s ease;
}
.pause > span {
  position: absolute;
  display: grid;
  place-items: center;
}
.pause svg {
  width: max(12px, calc(12 * var(--u)));
  height: max(12px, calc(12 * var(--u)));
}
.glyphPause svg {
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
}
.glyphPlay svg {
  fill: currentColor;
}
.glyphPlay,
.pause[aria-pressed='true'] .glyphPause {
  visibility: hidden;
}
.pause[aria-pressed='true'] .glyphPlay {
  visibility: visible;
}
@media (hover: hover) {
  .pause:hover {
    background: var(--glass-hover);
  }
}
.pause:focus-visible {
  outline: 2px solid var(--accent-ink);
  outline-offset: 2px;
}

/* The title and its line. */
.title {
  margin: calc(27.5 * var(--u)) 0 0;
  overflow: hidden;
  color: var(--ink);
  font-size: calc(21.6 * var(--u));
  font-weight: 700;
  line-height: calc(26 * var(--u));
  letter-spacing: 0;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.text {
  margin: calc(11.5 * var(--u)) 0 0;
  overflow: hidden;
  color: var(--ink-muted);
  font-size: calc(16.2 * var(--u));
  line-height: calc(22 * var(--u));
  letter-spacing: 0.012em;
  white-space: nowrap;
  text-overflow: ellipsis;
}

/* Narrow: larger type for its size, on two lines when it must, in a taller band. */
@container (max-width: 479px) {
  .scene {
    --text: 92px;
  }
  .title {
    margin-top: 16px;
    font-size: 15px;
    line-height: 20px;
  }
  .text {
    display: -webkit-box;
    margin-top: 6px;
    font-size: 12px;
    line-height: 17px;
    white-space: normal;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 2;
  }
}

/* For screen readers only. */
.hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

@media (prefers-reduced-motion: reduce) {
  .pause {
    transition: none;
  }
}
```

### insight-card/drawings.tsx

```tsx
/*
 * The insight card's drawings: the backdrop of its picture (the funnel on the left, the light
 * streaks on the right), the glyphs of the sources that flow in, and the folders that come out.
 * Every drawing is static; the card moves the boxes that hold them.
 */

/** The picture's box, in the units every size of the card is a multiple of. */
export const MEDIA = { width: 512, height: 277 };
/** Where the scanner stands, across the picture. */
export const SCANNER = 223;

/** The funnel's upper and lower edges, from the left side to the scanner. */
export const FUNNEL_TOP = 'M0 0C70 50 130 95 223 105';
export const FUNNEL_BOTTOM = 'M0 277C60 240 120 195 223 191';

/** A point of a cubic Bézier curve. */
const bezier = (t: number, a: number, b: number, c: number, d: number) =>
  (1 - t) ** 3 * a + 3 * (1 - t) ** 2 * t * b + 3 * (1 - t) * t ** 2 * c + t ** 3 * d;

/**
 * The inside of the funnel, kept `inset` units clear of its edges (a negative inset reaches past
 * them), as a CSS polygon in shares of the left part of the picture (the scanner's width by the
 * picture's height).
 */
export function funnelPolygon(inset: number) {
  const steps = 16;
  const top: string[] = [];
  const bottom: string[] = [];
  for (let i = 0; i <= steps; i += 1) {
    const t = i / steps;
    const x = bezier(t, 0, 70, 130, SCANNER);
    top.push(
      `${((x / SCANNER) * 100).toFixed(2)}% ${(((bezier(t, 0, 50, 95, 105) + inset) / MEDIA.height) * 100).toFixed(2)}%`,
    );
    const xb = bezier(t, 0, 60, 120, SCANNER);
    bottom.unshift(
      `${((xb / SCANNER) * 100).toFixed(2)}% ${(((bezier(t, 277, 240, 195, 191) - inset) / MEDIA.height) * 100).toFixed(2)}%`,
    );
  }
  return `polygon(${[...top, ...bottom].join(', ')})`;
}

/**
 * The picture behind everything that moves: the funnel, its glow, the blue beyond the scanner. Its
 * gradients carry `ids`, a prefix unique to the card; the folders use two of them.
 */
export function Backdrop({ className, ids }: { className?: string; ids: string }) {
  return (
    <svg
      className={className}
      viewBox={`0 0 ${MEDIA.width} ${MEDIA.height}`}
      preserveAspectRatio="none"
      aria-hidden="true"
    >
      <defs>
        <linearGradient id={`${ids}-left`} x1="0" x2="1" y1="0" y2="0">
          <stop offset="0" stopColor="#4c9bf6" />
          <stop offset="0.55" stopColor="#30a6fa" />
          <stop offset="1" stopColor="#1cb9fd" />
        </linearGradient>
        <radialGradient id={`${ids}-left-low`} cx="0" cy="1" r="0.9">
          <stop offset="0" stopColor="#7cb2ff" stopOpacity="0.7" />
          <stop offset="1" stopColor="#7cb2ff" stopOpacity="0" />
        </radialGradient>
        <linearGradient id={`${ids}-funnel`} x1="0" x2="1" y1="0" y2="0">
          <stop offset="0" stopColor="#98aee4" />
          <stop offset="0.07" stopColor="#8cb0fb" />
          <stop offset="0.2" stopColor="#7caef9" />
          <stop offset="0.36" stopColor="#71affc" />
          <stop offset="0.5" stopColor="#70b5fa" />
          <stop offset="0.63" stopColor="#6db9fc" />
          <stop offset="0.75" stopColor="#78c3f9" />
          <stop offset="0.87" stopColor="#89cdf8" />
          <stop offset="0.95" stopColor="#8cd8f8" />
          <stop offset="1" stopColor="#8ee6fa" />
        </linearGradient>
        <linearGradient id={`${ids}-right`} x1="0" x2="0" y1="0" y2="1">
          <stop offset="0" stopColor="#4282f4" />
          <stop offset="1" stopColor="#4c8ef4" />
        </linearGradient>
        <radialGradient id={`${ids}-corner`} cx="1" cy="0" r="0.75">
          <stop offset="0" stopColor="#2563d2" stopOpacity="0.55" />
          <stop offset="1" stopColor="#2563d2" stopOpacity="0" />
        </radialGradient>
        {/* Across a streak: a soft rise and fall, so that it has no edge. */}
        <linearGradient id={`${ids}-streak`} x1="0" x2="1" y1="0" y2="0">
          <stop offset="0" stopColor="#b8d4ff" stopOpacity="0" />
          <stop offset="0.25" stopColor="#b8d4ff" stopOpacity="0.3" />
          <stop offset="0.5" stopColor="#b8d4ff" stopOpacity="1" />
          <stop offset="0.75" stopColor="#b8d4ff" stopOpacity="0.3" />
          <stop offset="1" stopColor="#b8d4ff" stopOpacity="0" />
        </linearGradient>
        <linearGradient id={`${ids}-front`} x1="0" x2="0" y1="0" y2="1">
          <stop offset="0" stopColor="#fcfcfc" />
          <stop offset="1" stopColor="#f0f0f0" />
        </linearGradient>
        <linearGradient id={`${ids}-arrow`} x1="0" x2="1" y1="0" y2="1">
          <stop offset="0" stopColor="#e2e2e2" />
          <stop offset="1" stopColor="#d2d2d2" />
        </linearGradient>
        <linearGradient id={`${ids}-shade`} x1="0" x2="1" y1="0" y2="0">
          <stop offset="0" stopColor="#1f5fd0" stopOpacity="0" />
          <stop offset="0.5" stopColor="#1f5fd0" stopOpacity="1" />
          <stop offset="1" stopColor="#1f5fd0" stopOpacity="0" />
        </linearGradient>
      </defs>
      {/* Beyond the scanner: blue, swept by soft diagonal light. */}
      <rect
        x={SCANNER}
        width={MEDIA.width - SCANNER}
        height={MEDIA.height}
        fill={`url(#${ids}-right)`}
      />
      <rect
        x={SCANNER}
        width={MEDIA.width - SCANNER}
        height={MEDIA.height}
        fill={`url(#${ids}-corner)`}
      />
      {/* Each streak is a band leaning down to the right, its gradient across it. */}
      <g transform="rotate(-12.6 331 138.5)">
        <rect x="296" y="-30" width="70" height="337" fill={`url(#${ids}-streak)`} opacity="0.16" />
        <rect x="301" y="120" width="60" height="190" fill={`url(#${ids}-streak)`} opacity="0.28" />
      </g>
      <g transform="rotate(4 278 138.5)">
        <rect x="254" y="120" width="48" height="190" fill={`url(#${ids}-shade)`} opacity="0.16" />
      </g>
      <g transform="rotate(-7.2 422 138.5)">
        <rect x="397" y="-30" width="50" height="337" fill={`url(#${ids}-streak)`} opacity="0.12" />
      </g>
      {/* Before it: the funnel, bright at its mouth, and its two white edges. */}
      <rect width={SCANNER} height={MEDIA.height} fill={`url(#${ids}-left)`} />
      <rect width={SCANNER} height={MEDIA.height} fill={`url(#${ids}-left-low)`} />
      <path d={`${FUNNEL_TOP}V191C120 195 60 240 0 277z`} fill={`url(#${ids}-funnel)`} />
      <g fill="none" stroke="#fff" strokeLinecap="round">
        <path d={FUNNEL_TOP} strokeWidth="6" opacity="0.22" />
        <path d={FUNNEL_BOTTOM} strokeWidth="6" opacity="0.22" />
        <path d={FUNNEL_TOP} strokeWidth="1.8" />
        <path d={FUNNEL_BOTTOM} strokeWidth="1.8" />
      </g>
    </svg>
  );
}

/** The sources' glyphs, drawn on a 32 × 32 grid: a document, a chart, a megaphone, data, a chat. */
export const GLYPHS = [
  // A document.
  <svg key="document" viewBox="0 0 32 32" aria-hidden="true">
    <path
      d="M8 3.5h11.5L26 10v16.5a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2v-21a2 2 0 0 1 2-2z"
      fill="#2f6bff"
    />
    <path d="M19.5 3.5V8a2 2 0 0 0 2 2H26z" fill="#9db9ff" />
    <path
      d="M10.5 15h11M10.5 19h11M10.5 23h7"
      stroke="#fff"
      strokeWidth="1.9"
      strokeLinecap="round"
    />
  </svg>,
  // A bar chart.
  <svg key="chart" viewBox="0 0 32 32" aria-hidden="true">
    <rect x="5" y="16" width="6" height="11" rx="2" fill="#f5b51b" />
    <rect x="13" y="10" width="6" height="17" rx="2" fill="#22b55e" />
    <rect x="21" y="4.5" width="6" height="22.5" rx="2" fill="#2f6bff" />
  </svg>,
  // A megaphone.
  <svg key="megaphone" viewBox="0 0 32 32" aria-hidden="true">
    <path d="M5 13a2 2 0 0 1 2-2h4l11-6v22l-11-6H7a2 2 0 0 1-2-2z" fill="#ff6a3d" />
    <path d="M9.5 21.5l1.8 6h3.4l-1.6-6" fill="#c2410c" />
    <path
      d="M25.5 12.5a5 5 0 0 1 0 7"
      stroke="#ff6a3d"
      strokeWidth="2"
      strokeLinecap="round"
      fill="none"
    />
  </svg>,
  // A database.
  <svg key="database" viewBox="0 0 32 32" aria-hidden="true">
    <path
      d="M6 8c0-2.5 4.5-4.5 10-4.5S26 5.5 26 8v16c0 2.5-4.5 4.5-10 4.5S6 26.5 6 24z"
      fill="#1f2937"
    />
    <path
      d="M6 8c0 2.5 4.5 4.5 10 4.5S26 10.5 26 8M6 16c0 2.5 4.5 4.5 10 4.5S26 18.5 26 16"
      stroke="#fff"
      strokeWidth="1.6"
      fill="none"
    />
  </svg>,
  // A chat bubble.
  <svg key="chat" viewBox="0 0 32 32" aria-hidden="true">
    <path
      d="M6 5h20a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H13l-6 5v-5H6a3 3 0 0 1-3-3V8a3 3 0 0 1 3-3z"
      fill="#7a5cff"
    />
    <g fill="#fff">
      <circle cx="10.5" cy="14" r="1.8" />
      <circle cx="16" cy="14" r="1.8" />
      <circle cx="21.5" cy="14" r="1.8" />
    </g>
  </svg>,
];

/**
 * A folder, 144 × 114: its back with a tab, its front, a big download arrow into a tray. Its two
 * gradients are the backdrop's, named by `ids`.
 */
export function FolderDrawing({ ids }: { ids: string }) {
  return (
    <svg viewBox="0 0 144 114" aria-hidden="true">
      <path
        d="M0 7a7 7 0 0 1 7-7h45a8 8 0 0 1 6.6 3.5L64 11a8 8 0 0 0 6.6 3.5H137a7 7 0 0 1 7 7V107a7 7 0 0 1-7 7H7a7 7 0 0 1-7-7z"
        fill="#dddddd"
      />
      <rect y="15" width="144" height="99" rx="7" fill={`url(#${ids}-front)`} />
      <path d="M7 15.5h130" stroke="#fff" strokeWidth="1" />
      {/* The tray, notched where the arrow comes down, and the arrow, raised off the folder. */}
      <path
        d="M70 87h22a6 6 0 0 1 5 2.6l6.4 9a6 6 0 0 0 9.2 0l6.4-9a6 6 0 0 1 5-2.6H137a7 7 0 0 1 7 7v13a7 7 0 0 1-7 7H76a6 6 0 0 1-6-6z"
        fill="#e4e4e4"
      />
      <path d="M70 87.6h22a6 6 0 0 1 5 2.6" stroke="#d2d2d2" strokeWidth="1" fill="none" />
      <path
        d="M108 32.5a4.5 4.5 0 0 1 9 0v42.6l9.7-9.7a4.5 4.5 0 0 1 6.4 6.4l-17.4 17.4a4.5 4.5 0 0 1-6.4 0L91.9 71.8a4.5 4.5 0 0 1 6.4-6.4l9.7 9.7z"
        fill={`url(#${ids}-arrow)`}
        stroke="#fff"
        strokeWidth="1.2"
      />
    </svg>
  );
}
```