motion-underline
A line that draws itself under a phrase, from left to right, when it scrolls into view or on hover. Straight, dashed and dotted lines follow text that wraps across lines; shape="wave" and shape="zigzag" draw a wavy or a zigzag line. It uses the text color unless you set another.
Preview
Interactions that
Import
import 'motion-components/motion-underline' // just this one
import 'motion-components' // or the whole libraryUsage
<p>Interactions that <motion-underline>feel right</motion-underline>.</p>Wave and zigzag
Set shape="wave" for a wavy line, or shape="zigzag" for a zigzag. Both keep their text on one line, so use them on a word or a short phrase.
Springs, not
<motion-underline shape="wave">easing curves</motion-underline>
<motion-underline shape="zigzag">no jank</motion-underline>Dashed and dotted
Set shape="dashed" or shape="dotted". Both follow text that wraps, like the straight line.
A
<motion-underline shape="dashed">dashed note</motion-underline>
<motion-underline shape="dotted" style="--mc-mark-thickness: 3px">dotted term</motion-underline>Thick and colored
Built for
<motion-underline style="--mc-mark-color: #2563eb; --mc-mark-thickness: 4px">every framework</motion-underline>On hover
With trigger="hover" the line draws when the pointer enters and undraws when it leaves, from wherever it is.
<a href="/guide/"><motion-underline trigger="hover">Read the guide</motion-underline></a>Sweep
Call sweep() on a drawn line to run it across once: it erases from its start, leaves at its end and draws in again. For example, on hover over a link whose line is already drawn.
const link = document.querySelector('a.more')
const underline = link.querySelector('motion-underline')
link.addEventListener('pointerenter', () => underline.sweep())Loop
Add loop to draw, hold, undraw and draw again, like a hand that keeps underlining.
hold sets how long the line stays, gap how long it stays away. The line erases from its start, so it leaves at its end.
Still
<motion-underline loop hold="2">spring physics</motion-underline>Properties
| Attribute | Property | Type | Default | Description |
|---|---|---|---|---|
| | | string | 'view' | What draws the mark: |
| | | number | 0.6 | Spring duration of the drawing, in seconds |
| | | number | 0 | Seconds to wait before drawing. Use it to draw several marks in one sentence one after another |
| | | number | 0 | Spring bounciness. |
| | | boolean | false | Draw, hold, undraw and draw again on repeat. With |
| | | number | 1.6 | With |
| | | number | 0.5 | With |
| | | boolean | false | Pause the loop while the pointer is over the mark, resuming on leave |
| | | boolean | true | With |
| | | number | 0.6 | Share of the element, from 0 to 1, that must be visible before it draws |
| | | string | 'line' | |
CSS custom properties
| Property | Type | Default | Description |
|---|---|---|---|
| | color | currentColor | Color of the line |
| | length | 2px | Thickness of the line |
JavaScript API
The mark has the shared playback API: play() draws it, cancel() removes it, finish() shows it fully drawn and replay() draws it again from the start. It fires motion-start, motion-finish and motion-cancel. With trigger="hover", each draw and undraw is a playback run too, so it fires motion-start and motion-finish. See the JavaScript API.
sweep() runs a fully drawn mark across once: it erases from its start, so it leaves at its end, then draws in again. It is a playback run, so it fires motion-start and motion-finish, and pause(), finish() and cancel() act on it. It does nothing under reduced motion or while the mark is not fully drawn.
Accessibility
The mark is decoration: the text stays real, selectable and read by screen readers exactly as written. With trigger="hover" the line follows the pointer only; give links their own focus style. Respects prefers-reduced-motion: the mark appears fully drawn at once, with no animation, and loop never starts.