# Bubble Text (Moonarc)

Swells the letter under the cursor to the heaviest weight and lifts it; its neighbours follow at half and a quarter, and everything settles when the pointer leaves. Letters split on the server, neighbours found with :has(). Pure CSS on a variable font.

- Import: `import BubbleText from '@moonarc/core/BubbleText'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/bubble-text.json`
- Tier A · category text · trigger hover
- Readout: `<BubbleText min={400} max={800}>`
- Browser support: widely (Chrome 105 · Firefox 121 · Safari 15.4)
- Measured cost: 0 B JS (CSS 5.2 kB raw)
- Page: https://moonarc.dev/components/bubble-text/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `text` | `string` | none | The text; split into words and characters on the server, so lines break between words only. |
| `as` | `HTMLTag` | `'span'` | Element to render. |
| `min` | `number` | `400` | Resting weight. |
| `max` | `number` | `800` | Weight under the cursor. The neighbours get the midpoint and the quarter. |
| `duration` | `number` | `preset (ui: 450)` | Release duration in ms; the swell itself is the fast duration. |

## Usage

```astro
<h1><BubbleText text="Hover the letters." /></h1>
<BubbleText text="light to black" min={300} max={900} duration={300} />
```

## Reduced motion

The weight still follows the cursor, because that is direct manipulation, but it changes instantly and nothing lifts.

## With ClientRouter

CSS-only; nothing to rebind.

## Craft

- Swell in 150 ms, release on the preset duration: the asymmetry is what makes it feel like pressing into something soft.
- Neighbours at half and a quarter through :hover + * and :has(+ :hover): a bump, not a spotlight. Three letters wide is the width of a fingertip; the bump stops at a word boundary, which is also where the eye stops.
- font-weight on a variable axis is one property with no layout trick; the line does reflow as widths change, so keep it to headlines.
- The lift is the preset's hover travel token (2 px on snap, 6 px on lively), so the swell keeps the page's scale.
- A visually hidden span (.ma-sr), aria-hidden letters: one string to assistive tech.
- A character is a grapheme (Intl.Segmenter), so an emoji with a skin tone or a letter with a combining accent counts as one. The inline-block characters would follow the page's direction, so the element takes dir="auto" and Latin text on a right-to-left page still runs left to right.

## Replaces

- BubbleText (Hover.dev)
- VariableProximity (React Bits) under the cursor

## Source

```astro
---
/**
 * BubbleText — the letter under the cursor swells to the heaviest weight
 * and lifts a little; its neighbours follow at half and a quarter. Letters
 * are split on the server; the neighbours are :hover + * and :has(+ :hover),
 * so it is pure CSS. Needs a variable font for a continuous swell; static
 * families step between the weights they have.
 */
import type { HTMLAttributes, HTMLTag } from 'astro/types';

interface Props extends HTMLAttributes<'span'> {
  /** The text. */
  text: string;
  /** Element to render. */
  as?: HTMLTag;
  /** Resting weight. */
  min?: number;
  /** Weight under the cursor. */
  max?: number;
  /** Duration in ms. Unset: the preset's --ma-duration. */
  duration?: number;
}

const { text, as: Tag = 'span', min = 400, max = 800, duration, class: className, style, ...rest } = Astro.props;
const vars = [`--ma-bubble-min:${min}`, `--ma-bubble-max:${max}`, duration !== undefined && `--ma-bubble-dur:${duration}ms`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
// graphemes, not code points: an emoji with a skin tone or a letter with a combining accent swells as one letter
const graphemes = (s: string) => Array.from(new Intl.Segmenter().segment(s), (g) => g.segment);
// dir="auto": words and letters are inline-blocks, which bidi orders by the page's direction, so Latin text on a
// right-to-left page read back to front. The element takes its direction from its own text instead.
---

<Tag class:list={['ma-bubble', className]} dir="auto" style={vars} {...rest}>
  {text.split(/(\s+)/).map((word) =>
    /^\s+$/.test(word) ? (
      ' '
    ) : (
      <span class="ma-bubble__word" aria-hidden="true">
        {graphemes(word).map((c) => <span class="ma-bubble__char">{c}</span>)}
      </span>
    ),
  )}
  <span class="ma-sr">{text}</span>
</Tag>

<style is:global>
  @layer components {
    :where(.ma-bubble) {
      white-space: pre-wrap;
      font-weight: var(--ma-bubble-min, 400);
    }
    /* a word is one inline-block, so lines break between words, never between letters */
    :where(.ma-bubble__word) {
      display: inline-block;
      white-space: nowrap;
    }
    :where(.ma-bubble__char) {
      display: inline-block;
      font-weight: var(--ma-bubble-min, 400);
      transition:
        font-weight var(--ma-bubble-dur, var(--ma-duration)) var(--ma-ease),
        translate var(--ma-bubble-dur, var(--ma-duration)) var(--ma-ease);
    }
    @media (hover: hover) {
      :where(.ma-bubble__char:hover) {
        font-weight: var(--ma-bubble-max, 800);
        translate: 0 calc(-1 * var(--ma-travel-hover));
        transition-duration: var(--ma-duration-fast);
      }
      /* neighbours: half the swell, then a quarter */
      :where(.ma-bubble__char:hover + .ma-bubble__char),
      :where(.ma-bubble__char:has(+ .ma-bubble__char:hover)) {
        font-weight: calc((var(--ma-bubble-min, 400) + var(--ma-bubble-max, 800)) / 2);
        translate: 0 calc(-0.5 * var(--ma-travel-hover));
        transition-duration: var(--ma-duration-fast);
      }
      :where(.ma-bubble__char:hover + .ma-bubble__char + .ma-bubble__char),
      :where(.ma-bubble__char:has(+ .ma-bubble__char + .ma-bubble__char:hover)) {
        font-weight: calc(var(--ma-bubble-min, 400) + (var(--ma-bubble-max, 800) - var(--ma-bubble-min, 400)) / 4);
        translate: 0 calc(-0.25 * var(--ma-travel-hover));
        transition-duration: var(--ma-duration-fast);
      }
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-bubble__char) {
        transition: none;
        translate: 0 0;
      }
    }
  }
</style>

```
