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 feel right.

Import

import 'motion-components/motion-underline'   // just this one
import 'motion-components'               // or the whole library

Usage

<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 easing curves, and no jank.

<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 dashed note and a dotted term.

<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 every framework.

<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 spring physics.

<motion-underline loop hold="2">spring physics</motion-underline>

Properties

Attribute Property Type Default Description
trigger trigger string 'view' What draws the mark: 'view' when it scrolls into view, 'hover' on pointer enter (it undraws on leave), or 'mount' when it connects
duration duration number 0.6 Spring duration of the drawing, in seconds
delay delay number 0 Seconds to wait before drawing. Use it to draw several marks in one sentence one after another
bounce bounce number 0 Spring bounciness. 0 keeps the stroke from overshooting the text
loop loop boolean false Draw, hold, undraw and draw again on repeat. With trigger="view" the cycle runs while the mark is on screen
hold hold number 1.6 With loop, seconds the mark stays drawn before it undraws
gap gap number 0.5 With loop, seconds the mark stays undrawn before it draws again
pause-on-hover pauseOnHover boolean false Pause the loop while the pointer is over the mark, resuming on leave
once once boolean true With trigger="view", draw only the first time. Set once="false" to undraw when it leaves and draw again on every entry. Ignored with loop
threshold threshold number 0.6 Share of the element, from 0 to 1, that must be visible before it draws
shape shape string 'line' 'line', 'dashed' or 'dotted', which follow wrapping text, or 'wave' and 'zigzag', which keep the text on one line

CSS custom properties

Property Type Default Description
--mc-mark-color color currentColor Color of the line
--mc-mark-thickness 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.