Skip to content

Components / Text

Bubble Text

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.

Live demo

Hover the letters

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

0 B

CSS 5.2 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Baseline · widely available

Chrome 105 · Firefox 121 · Safari 15.4; needs font-variation, has.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import BubbleText from '@moonarc/core/BubbleText';
---
<h1><BubbleText text="Hover the letters." /></h1>
<BubbleText text="light to black" min={300} max={900} duration={300} />
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/bubble-text.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.

BubbleText.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>

Props

PropTypeDefaultDescription
textstringnoneThe text; split into words and characters on the server, so lines break between words only.
asHTMLTag'span'Element to render.
minnumber400Resting weight.
maxnumber800Weight under the cursor. The neighbours get the midpoint and the quarter.
durationnumberpreset (ui: 450)Release duration in ms; the swell itself is the fast duration.

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.

Why it is built this way

Replaces: BubbleText (Hover.dev) · VariableProximity (React Bits) under the cursor. See the migration table.

Magnetic

Pointer

A button or icon is pulled toward the cursor while it is nearby and springs back when it leaves.

every browserpointerhover

A label rolls upward into a copy of itself on hover, letter by letter from the left, and rolls back when the pointer leaves.

every browserhover

A wave of boldness rolls through the letters of a line, each character animating its font weight on its own delay.

every browseralways

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown