Start
Get Started
Three steps: load it, wrap a heading, pick colors. Working with a coding assistant? Skip to Build with AI.
Load It
In your terminal
npm install hue-hop
In your JavaScript
import 'hue-hop';
Add drift with
import 'hue-hop/drift.css';.
In your HTML
<script
type="module"
src="https://cdn.jsdelivr.net/npm/hue-hop@0.1/dist/hue-hop.js"
></script>
Add drift with a
<link>
to
dist/drift.css
in the same folder.
Copy
dist/hue-hop.js
from the package into your site and load it like any other
file. Add drift by copying
dist/drift.css
too and loading it with a
<link>.
In your HTML
<script
type="module"
src="/js/hue-hop.js"
></script>
The import defines
<hue-hop>, unless something on the page already did. Types ship in the
package. The
HueHop
class is exported too, for
another tag.
Wrap a Heading
Put the element inside the heading, around the words. Pick a look
with
hue.
Midnight Marauders
<h1>
<hue-hop hue="dusk">Midnight Marauders</hue-hop>
</h1>
The heading stays one piece of text. Select, find, and copy work like on any heading. Kerning holds, and links inside keep working. Nothing moves when the colors load.
Pick Colors
Use
hue
for one of the seven shipped pairs, or
start
and
end
for your own. Set only one end and Hue Hop picks the other. See
Hues
and
Your Own Colors.
Build with AI
Any coding assistant that can read a URL can add Hue Hop for you.
Point it at
llms.txt, a short plain-text version of these docs written for assistants.
It has the install, the attributes, and the rules an assistant
gets wrong without them (headings and names only, check your own
ends, a
color-scheme
for the dark side).
Paste-In Prompt
For a one-off. Paste it into the chat.
In your chat
Add Hue Hop to this project. Read https://huehop.dev/llms.txt first, and follow its rules. Before you add it, ask me which heading it goes on, and whether to use a shipped hue or my brand colors.
Project Rules
For every session after this one. Add it to the instructions file
your assistant reads on its own. Codex, Cursor, and Copilot read
AGENTS.md. Claude Code reads
CLAUDE.md. Add a line that says
@AGENTS.md
to it and Claude Code reads the same rules.
In your instructions file
## Hue Hop
- Before adding or changing a `<hue-hop>`, read https://huehop.dev/llms.txt and follow its Rules.
Manifest and Types
The package ships two files for your tools.
custom-elements.json
lists every attribute, property, and custom property, and the
package's
customElements
field points tools at it.
hue-hop.d.ts
types the element, React JSX included. See
Frameworks and Editors.
Use
Hues
Seven ready-made pairs come with the element. Pick one with
hue. Each pair has a dark side and a light side. The names describe
the dark side.
| Sample | hue |
Goes from (dark side) |
|---|---|---|
|
|
dusk |
pale sky to blue-violet |
|
|
reef |
pale aqua to blue |
|
|
fern |
lime to pine |
|
|
ember |
pale amber to red |
|
|
guava |
peach to magenta |
|
|
orchid |
blush pink to aubergine |
|
|
honey |
butter yellow to rust |
On the light side the starts are deeper, so they stay readable on light pages. Flip the side switch in the header to see them.
Every letter of the seven hues passes 4.5:1 contrast, with drift
off or on at its default range. That's checked on light pages from
#f5f5f5
to
#fff
and dark pages from
#000
to
#1a1a1a. On other backgrounds, you'll need to check them yourself. Every
hue also sets
--hop-lift
to
0.021.
color-scheme.
They use
light-dark(), so without a
color-scheme
on the page you always get the light side. See
Light and Dark.
Display
Add
display
for large headings. Its colors sit closer to the page color and
pass 3:1, the bar for large text. It isn't meant for body-size
text.
display
only works with
hue. On its own it does nothing.
Can I Kick It?
<h2>
<hue-hop hue="orchid" display>Can I Kick It?</hue-hop>
</h2>
Change One End
A
start
or
end
attribute wins over the hue's, and so does a
--hop-*
value set on the element. So you can pick a hue and change one
end.
Check the Rhime
<h2>
<hue-hop hue="dusk" end="crimson">Check the Rhime</hue-hop>
</h2>
A changed end is your color, so its contrast is yours to check. See Keep Them Readable.
A rule on the element beats the hue, even inside a cascade layer,
so Tailwind v4 utilities work with no setup. A value on a parent,
like
:root, loses to the hue. See
Which Value Wins.
Your Own Colors
Set
start
and
end
to any two colors.
start
colors the first letter and
end
the last, and every letter between gets one flat step on the way.
They set
--hop-start
and
--hop-end
on the element.
Ex-Factor
<h2>
<hue-hop start="indianred" end="crimson">Ex-Factor</hue-hop>
</h2>
Any CSS color works,
var(--brand)
and
light-dark()
included. A
var()
that points nowhere counts as left out, so Hue Hop picks its
partner. A typo (start="tael") shows plain text. Set neither end and the text keeps its normal
color.
One End
Set only one and Hue Hop picks the other. It keeps your lightness and intensity and turns the hue 50 degrees around the color wheel, away from yellow. Yellow can't stay bright at a readable lightness.
Me Myself and I
<h2>
<hue-hop start="darkcyan">Me Myself and I</hue-hop>
</h2>
As Custom Properties
Set
--hop-start
and
--hop-end
anywhere they'll inherit:
:root, a class, your design tokens, or one element's
style. Use them for colors shared across a site, or colors that follow
a theme.
The World Is Yours
<div class="brand">
<h2><hue-hop>The World Is Yours</hue-hop></h2>
</div>
.brand h2 {
--hop-start: darkcyan;
--hop-end: royalblue;
}
Each new attribute value adds a small style rule to the page. For
a color that changes every frame, set
--hop-start
in
style
instead.
Which Value Wins
Strongest first. Each one beats everything below it.
- Always wins
-
The
styleattribute. - Beats attribute
-
An unlayered rule more specific than
hue-hop[start], like.card h2 hue-hop. - The attribute
-
startorendon the element. - Below that
- A plain class rule, then any rule in a cascade layer.
- The hue
-
The
hueattribute's own colors. - Weakest
-
A value inherited from a parent, like
:root.
From JavaScript
el.start
and
el.end
read and set the attributes. Setting one to
null
removes it. So frameworks that set properties instead of
attributes (Angular, Solid, Lit's
.start=) should work too.
Keep Them Readable
The element can't see the background, so it can't check your
colors for you. These rules keep both ends readable. Write your
ends in
oklch()
so you can see the lightness (the first number).
| Page | Body text (4.5:1) | Display (3:1) |
|---|---|---|
Light,
#f5f5f5
to
#fff
|
both ends at
52%
or darker
|
both ends at
62%
or darker
|
Dark,
#000
to
#1a1a1a
|
both ends at
65%
or lighter
|
both ends at
55%
or lighter
|
These hold for any hue and any chroma a screen can show. Lightness steps evenly from one end to the other, so if both ends pass, most letters between them do too. Ends far apart on the color wheel (green to violet, say) can still dip in the middle, and lift makes that more likely. Check the middle letters.
On any other background, check the contrast yourself. The ready-made hues go a little past these numbers, because each one was measured for its own colors.
Yellows can't stay bright that dark. At
52%, a yellow holds about half the color (OKLCH chroma) a blue or
pink can, so it goes dull. For a yellow start, use the display bar
or a dark page.
Or let the Studio do it. It checks every letter against your page color, and its Fit button moves the ends until every letter passes, or tells you it can't.
Light and Dark
The element has no theme logic. It reads
--hop-*
live, so when your theme changes them, the letters follow with no
re-render. Flip the side switch in the header. Examples that use a
hue
or
light-dark()
follow.
Pick colors per scheme in one of three ways. Tokens that already flip with your theme:
In your CSS
:root {
--hop-start: var(--brand-500);
--hop-end: var(--accent-500);
}
A scheme rule, or a class your theme switch sets (like
.dark):
In your CSS
:root {
--hop-start: teal;
--hop-end: royalblue;
}
@media (prefers-color-scheme: dark) {
:root {
--hop-start: aquamarine;
--hop-end: lightskyblue;
}
}
Or
light-dark(), which picks its first color on a light page and its second on a
dark one:
Hey Ya!
<div class="two-sides">
<h2><hue-hop>Hey Ya!</hue-hop></h2>
</div>
.two-sides {
--hop-start: light-dark(teal, aquamarine);
--hop-end: light-dark(royalblue, lightskyblue);
}
light-dark()
needs
color-scheme
on the page (:root { color-scheme: light dark; }). Without it you always get the light value. This page sets it,
which is why the example flips.
Drift
drift.css
slowly sways the hues back and forth. Load it once (see
Load It), then add
drift
to any element.
Mass Appeal
<h2>
<hue-hop hue="ember" drift>Mass Appeal</hue-hop>
</h2>
Two custom properties set the sway. Both are plain numbers.
| Property | Default | What it does |
|---|---|---|
--hop-drift-range |
24 |
How many degrees the hues sway each way. |
--hop-drift-cycle |
10 |
How many seconds one full sway there and back takes. |
Drift only turns hues, and lightness holds. Contrast can still shift a little as the hues turn, so leave some margin at the start and end colors. Drift stops when the visitor prefers reduced motion, and under forced colors and increased contrast.
A subclass under
another tag
drifts too, as long as it has text. Inside another component's
shadow root, load
drift.css
on the page and in that root.
Where It Fits
Short text that names something: a title, a name, a number. It isn't meant for body copy, which gets hard to read when every letter changes color.
<header>
<a href="/" class="logo">
<hue-hop hue="reef">Halftime</hue-hop>
</a>
<a href="/tour">Tour</a>
<a href="/shop">Shop</a>
</header>
Words by
<p>
Words by
<hue-hop hue="honey">Bonita Applebum</hue-hop>
</p>
Summertime
- Back in the Day
- Feel Me Flow
- Jump Around
<h2><hue-hop hue="fern">Summertime</hue-hop></h2>
<ol>
<li>Back in the Day</li>
<li>Feel Me Flow</li>
<li>Jump Around</li>
</ol>
Ether
Review · 6 min read
<h1>
<hue-hop hue="guava" display>Ether</hue-hop>
</h1>
<p>Review · 6 min read</p>
Links and other inline markup can sit inside, and they keep working. The colors run straight across them. Keep link underlines on: when only part of a heading is a link, the underline is the only thing that says so (WCAG 1.4.1).
Customize
Tuning the Ramp
Three custom properties shape the steps between your two ends.
None of them has an attribute. Set them once, on
:root
or a class, and every heading under it follows. One catch: a
hue
sets
--hop-lift
on the element itself, so to change lift under a hue, set it on
the element. The examples set them in
style
so you can see the one value that changes.
Curve
How the steps are spaced. At
1, the default, they're even. Higher stays near the start color
longer. Lower gets to the end color sooner.
Step in the Arena
<h2>
<hue-hop hue="ember" style="--hop-curve: 1">Step in the Arena</hue-hop>
</h2>
Lift
Extra color intensity (OKLCH chroma) for the middle letters, so
the blend doesn't go dull halfway. The two ends don't change. The
default is
0, and every ready-made hue sets
0.021.
Sky's the Limit
<h2>
<hue-hop start="oklch(60% 0.12 200)" end="oklch(60% 0.12 300)" style="--hop-lift: 0">Sky's the Limit</hue-hop>
</h2>
Shift
Turns each letter's hue by this many degrees around the color
wheel. Write a plain number (30, not
30deg). The default is
0. Lightness holds, so it's a quick way to try a pair in another
color without picking new ends.
Around the Way Girl
<h2>
<hue-hop hue="dusk" style="--hop-shift: 0">Around the Way Girl</hue-hop>
</h2>
Contrast can still move a little as the hues turn. A shifted ready-made hue isn't the one that was measured, so check it.
Styling Around It
<hue-hop>
is an inline element. Font, size, weight, and letter spacing work
on it the way they work on a
<span>. Style the heading around it as you already do.
- Your color
-
coloris the exception. The letter colors paint over it, so yours only shows where the effect is off: an older browser, neither end set, forced colors, or increased contrast. Set it anyway. It's the fallback. - Selected text
-
A
::selectionrule of yours that setscolorwins in every browser, and the letter colors come back after. Without one, Chrome and Safari keep the letter colors on the selection background. Firefox uses its own selection colors. - Gradient text
-
background-clip: textwith a transparent fill hides the effect, because the transparent fill wins. Use one or the other. - Links inside
-
They lose their own color in every state, hover and visited
included. They keep their focus ring and underline. Firefox
and Safari keep the underline in the link's color. Chrome
paints it in each letter's color, and
text-decoration-coloronly shows under the spaces. - Your highlights
- A search-term marker, say, paints above Hue Hop's, because every Hue Hop highlight has priority −1. See Highlights.
Coloring Things Nearby
Hue Hop only colors letters. To match a list marker, a badge or a
border, put
--hop-start
and
--hop-end
on a shared parent and use them in your own CSS.
Using Your Own Colors
Set both ends on the parent. No JavaScript, and it follows theme switches.
Summertime
- Back in the Day
- Feel Me Flow
- Jump Around
<div class="playlist">
<h2><hue-hop>Summertime</hue-hop></h2>
<ol>
<li>Back in the Day</li>
<li>Feel Me Flow</li>
<li>Jump Around</li>
</ol>
</div>
.playlist {
--hop-start: light-dark(green, lightgreen);
--hop-end: light-dark(teal, mediumaquamarine);
}
@media (forced-colors: none) and (prefers-contrast: no-preference) {
.playlist li:nth-child(1) {
--step: 0;
}
.playlist li:nth-child(2) {
--step: 0.5;
}
.playlist li:nth-child(3) {
--step: 1;
}
.playlist li::marker {
color: color-mix(
in oklch,
var(--hop-start),
var(--hop-end) calc(var(--step) * 100%)
);
}
}
--step
is each item's place on the ramp:
0
is the start color,
1
the end. A border or badge can skip the mix and use
var(--hop-end).
- Keep the media query
- Without it, markers stay colored under increased contrast while the letters go plain.
- Set both ends
- The letters pick a partner for a missing end. The mix can't, so markers stay plain.
- With drift
- Only the letters move. Markers hold still.
Using a
hue
Instead
Drop the
.playlist
rule and run this script. A
hue
sets its ends on the element itself, so nothing beside it can see
them. The script copies them to the parent, and they still follow
theme switches.
In your JavaScript
customElements.whenDefined('hue-hop').then(() => {
const parent = document.querySelector('.playlist');
const styles = getComputedStyle(parent.querySelector('hue-hop'));
for (const name of ['--hop-start', '--hop-end']) {
parent.style.setProperty(name, styles.getPropertyValue(name));
}
});
Middle markers look slightly less vivid than the letters, because
the mix leaves out
--hop-lift.
Integrate
Frameworks and Editors
It's a plain custom element. Plain HTML, Svelte, and Lit need nothing. TypeScript and React get their types from the package. Vue and Angular each need one setting.
TypeScript
hue-hop.d.ts
ships in the package. It adds
<hue-hop>
to
HTMLElementTagNameMap, so
document.querySelector('hue-hop')
comes back as a
HueHop. It also types the element in React JSX:
hue
(the seven names),
start,
end,
drift, and
display. Nothing to declare.
React
React needs nothing. It sets
start
and
end
as attributes or as properties, and the element reads both.
Extra attributes from the server: hops. Colors stay correct. If your test runner fails on console
errors, let that one line through.
Vue and Angular
Both need to be told that
<hue-hop>
isn't one of their own components. In Vue, add this to the Vue
plugin in
vite.config.js:
In vite.config.js
vue({
template: { compilerOptions: { isCustomElement: tag => tag === 'hue-hop' } },
})
In Angular, add
CUSTOM_ELEMENTS_SCHEMA
to the component that uses it:
In your Angular component
@Component({
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
Another Tag
The
HueHop
class is exported. To use another tag, register a subclass:
In your JavaScript
import { HueHop } from 'hue-hop';
customElements.define('my-hop', class extends HueHop {});
HueHop
itself won't work here. The import already registered it, and a
class can only be registered once. Use one tag per page or shadow
root. Two tags in one root make one of them fall back to plain
text.
A subclass that defines its own
observedAttributes
must include
...super.observedAttributes
and call
super.attributeChangedCallback(), or
start
and
end
stop updating.
The hues and drift follow your tag on their own.
The element attaches a shadow root holding one
<slot>, and your text stays where you wrote it. A subclass can attach
its own root in its constructor instead. Any later and the browser
throws. Keep a
<slot>
in it, or the text won't show. A closed root goes without the
hues, because the element can't reach it.
Tailwind and Cascade Layers
CSS on the element beats a
hue
with no setup, layer or not. So do
--hop-drift-range
and
--hop-drift-cycle. The one exception is a layered rule that sets
animation
on a drifting element.
drift.css
sits in a layer named
hue-hop, so list that first for your rule to win.
In your CSS
@layer hue-hop, theme, base, components, utilities;
Content Security Policy
The element adds its styles from JavaScript as constructed
stylesheets (built in script, not written into the page), not a
<style>
tag. So your policy doesn't need
style-src 'unsafe-inline'
for it.
drift.css
is a plain stylesheet file, so load it from a host your policy
already allows.
Editors
custom-elements.json
describes every attribute, property, and custom property in the
Custom Elements Manifest format. The package's
customElements
field points to it, so any tool that reads the format can find it.
Testing Your App
Screenshot Tests
Drift keeps moving, so screenshots of it won't match from run to run. Emulate reduced motion and drift holds still. In Playwright:
In your Playwright test
await page.emulateMedia({ reducedMotion: 'reduce' });
Reading a Letter's Color
Each step has its own highlight:
hue-hop-0
for the first,
hue-hop-1
for the next, and on up. Up to 64 letters, that's one per letter.
Read one with
getComputedStyle:
In your JavaScript
getComputedStyle(el, '::highlight(hue-hop-0)').color;
The value is fixed for the current scheme. A copy of it won't
follow a theme switch. For colors that do, see
Coloring Things Nearby. Stay below
el.hops: a higher step can return a wrong color.
Test DOMs
jsdom and happy-dom have no highlights, so the element goes inert
there. It's defined, it never draws, and it logs nothing. That's
what Vitest's
jsdom
and
happy-dom
environments do, checked with jsdom 30.1, happy-dom 21.0, and
Vitest 5.1.
Wiring up a DOM by hand? The element reads
CSS
as it loads. Copy only
window,
document,
HTMLElement, and
customElements
onto the global and the import throws
ReferenceError: CSS is not defined. Copy
window.CSS
too and it loads in both. On jsdom, this line before the import
works too:
Before the import
globalThis.CSS ??= {};
el.hops
is
0
off the page or without support, test DOMs included. The
hops
attribute
keeps its last value.
Before It Loads
Without JavaScript, or before the element loads, the heading is plain text in its normal color. When the colors arrive, nothing moves, so there's no layout shift to prevent and nothing to load ahead of it.
Older browsers show the plain heading too. The element never paints a wrong color. See Browser Support.
Reference
API
The tables come straight from
custom-elements.json.
Attributes
| Attribute | Type | Does |
|---|---|---|
start | string | The color of the first letter, which sets --hop-start on this element. Left out, it comes from hue if one is set. Otherwise it's picked from the end color. |
end | string | The color of the last letter, which sets --hop-end on this element. Left out, it comes from hue if one is set. Otherwise it's picked from the start color. |
drift | boolean | Slowly sways the hues back and forth, needs drift.css, and holds still when the visitor prefers reduced motion. |
hue | dusk reef fern ember guava orchid honey | Picks one of the seven ready-made color pairs. |
display | boolean | Switches the picked hue to its large-heading version, which passes 3:1 instead of 4.5:1. |
hops | number | How many color steps the letters use, one per letter up to 64, written by the element itself, so don't set it. |
Properties
| Property | Type | Does |
|---|---|---|
hops | number, read-only | How many color steps the letters use, one per letter up to 64, or 0 when the element is off the page or the browser lacks support. |
start | string | null | The color of the first letter, mirroring the start attribute, where null removes it. |
end | string | null | The color of the last letter, mirroring the end attribute, where null removes it. |
CSS Custom Properties
| Property | Type | Default | Does |
|---|---|---|---|
--hop-start | <color> | The color of the first letter. Left out, it's picked from --hop-end. | |
--hop-end | <color> | The color of the last letter. Left out, it's picked from --hop-start. | |
--hop-curve | <number> | 1 | How the steps are spaced: 1 is even, higher stays near the start color longer, and lower reaches the end color sooner. |
--hop-lift | <number> | 0 | How much extra color intensity (OKLCH chroma) the middle letters get, so the blend doesn't go dull halfway. |
--hop-shift | <number> | 0 | Turns each letter's hue by this many degrees, written as a plain number (30, not 30deg). |
--hop-drift-range | <number> | 24 | How many degrees the hues sway each way when drift is on, written as a plain number. |
--hop-drift-cycle | <number> | 10 | How many seconds one full sway there and back takes when drift is on, written as a plain number. |
Highlights
The colors are highlights in
CSS.highlights, one per step:
hue-hop-0
up to
hue-hop-63. Every one has priority −1, so your own highlights (a search-term
marker, say) paint above them. The names are public and won't
change.
CSS.highlights.clear()
removes the colors until the text changes or the element
reconnects. To read a letter's color, see
Reading a Letter's Color.
The
hops
Attribute
The element writes its step count on itself, like
hops="13". Its own stylesheet reads it. You can leave it alone. The element
writes it when the count changes.
Each letter gets its own step, up to 64. Spaces don't count. Longer text shares steps, so a 200-letter heading still runs the whole ramp with about three letters per color. Past 64, every extra step would slow each restyle on the page, theme switches and drift included.
Type in the box to watch the count.
Protect Ya Neck
<h2>
<hue-hop hue="guava">Protect Ya Neck</hue-hop>
</h2>
The code leaves
hops
out, like every example here, because you never write it.
el.hops
returns the same count as a read-only number. It's
0
when the element is off the page (the attribute keeps its last
value) or the browser lacks support. With server rendering, React
can warn about the attribute. See
React.
Exports
The module exports the
HueHop
class and defines
<hue-hop>
with it, unless something on the page already did. To register it
under another name, see
Another Tag.
Accessibility
Handled for You
- Screen readers
- Screen readers hear the text once, as words. The text is never split.
- High contrast
-
Forced colors mode turns the effect off, and so does any OS
contrast setting (
prefers-contrastother thanno-preference). The heading falls back to its normal color. - Reduced motion
- Drift stops when the visitor prefers reduced motion.
- Find and copy
- Find in page, selection, and copy work as they do on any heading.
- Hue contrast
-
Every letter of the seven
hues
passes 4.5:1 on the backgrounds listed there, or 3:1 with
display.
Your Part
- Your contrast
- Check contrast at your own ends. The element can't see the background. See Keep Them Readable.
- Link underlines
- Keep link underlines on. When only part of a heading is a link, the underline is the only thing that says so (WCAG 1.4.1).
- Where to use it
- Use it on headings and names, not body copy. Body copy gets hard to read when every letter changes color.
- Display
-
Use
displayonly for large text. It passes 3:1, not 4.5:1. - Hidden text
-
Put screen-reader-only text outside the element. Inside, it
still uses up hops, so the visible letters may not reach
--hop-end.
Browser Support
The colors need two things from a browser: the CSS Custom Highlight API, and custom properties that work inside a highlight. These versions and anything newer have both:
-
Chrome 134+ -
Firefox 140+ -
Safari 18+
CSS.highlights
as it loads and does nothing without it. There's no polyfill.
Chrome 105 to 133 have the API but can't read custom properties
inside a highlight, so they show plain text too, never a wrong
color. Safari 17 is unmeasured.
How It Works
Hue Hop never splits your text. A word stays one element in one text node, and the colors are painted over it. Here's one word taken apart. Move the fader to change how its colors are spaced.
Aquemini
<h2>
<hue-hop hue="dusk" style="--hop-curve: 1">Aquemini</hue-hop>
</h2>
- Ranges
-
The CSS Custom Highlight API paints the colors. Each letter
gets a
StaticRangeover the real text node, and each step is one highlight. So there are no spans. The element adds two things: itshopsattribute, and a shadow root holding one<slot>. Your text stays where you wrote it. - Colors
-
Each step's color is worked out in CSS:
color-mix()in OKLCH between the two ends, spaced by the curve, lifted in the middle, then turned by the shift and the drift. So a theme switch or a--hop-*change recolors the letters with no JavaScript. Read the formula in the repo. - Stylesheets
-
The styles arrive as constructed stylesheets. The hues sit in
the element's own shadow root, as
:hostrules, which is why CSS on the element beats them and an inherited value doesn't. The rest goes on the document, or on the shadow root the element sits in. Each distinctstartorendvalue becomes one rule in a sheet for that root. At 256 rules, that sheet is rebuilt from the elements still on the page. - 64 steps
- In Chromium and Safari each restyle costs about the square of the step count, so longer text shares steps.
Known Limits
Colors
- The element can't check contrast at the start and end colors, so that part is up to you.
-
A typo in a color (
start="tael") shows plain text. Avar()that points nowhere counts as left out, so Hue Hop picks its partner instead. - Ends far apart on the color wheel, and lift, can push the middle letters past what an sRGB screen can show. They get clipped, by an amount that depends on the colors, and on a white page that can lower their contrast. Check the middle letters.
-
User stylesheets and extensions that force a text color still
show the colors. A highlight's
colorpaints above the element's color,!importantincluded.
Text
- Links inside keep their focus ring and underline. They lose their own color in every state, hover and visited included.
-
Firefox and Safari keep a link's underline in the link's color.
Chrome paints it in each letter's color, and
text-decoration-coloronly shows under the spaces. -
Selected text: a
::selectionrule of yours that setscolorwins in every browser. Without one, Chrome and Safari keep the letter colors on the selection background, and Firefox uses its own selection colors. - Find in page still shows its matches. The colors come back after.
- Color emoji keep their own colors and use one hop each.
- Chrome and Firefox split a ligature's colors at the step boundary. Safari paints the whole ligature in its first letter's color.
- A letter split across two elements (a base letter in one, a combining mark in the next) counts as two hops.
- The steps follow reading order, so mixed-direction text jumps around visually.
-
Text passed through your own
<slot>inside the element isn't colored. -
Screen-reader-only text inside the element still uses up hops,
so the visible letters may not reach
--hop-end. -
Gradient-text headings (
background-clip: textwith a transparent fill) hide the effect. The transparent fill wins.
Setup
- One tag per page or shadow root. Two tags in one root make one of them fall back to plain text.
- A subclass with a closed shadow root doesn't get the hues. Its letters still step if you set your own ends.
-
A subclass that defines its own
observedAttributesmust include...super.observedAttributesand callsuper.attributeChangedCallback(), orstartandendstop updating. - A subclass with no text, like one wrapping only an SVG, doesn't drift.
Troubleshooting
- Nothing is colored.
-
- An older browser. Below the floor the heading stays plain on purpose. See Browser Support.
-
A typo in a color (
start="tael"). Correcting the spelling fixes it. -
Both ends point at a
var()that doesn't exist. One missing end is fine, because Hue Hop picks a partner. With both missing there's nothing to step. Define the variable. - Forced colors or increased contrast is on. The effect turns off on purpose. See Accessibility.
-
Two tags in one page or shadow root, like
hue-hopand a subclass. One falls back to plain text, so stick to one tag. See Another Tag. -
You put your own
<slot>inside the element. Text passed through it isn't colored. Put the<hue-hop>around the text where it's written, not around the slot.
- The dark hues don't show.
-
No
color-schemeon the page, solight-dark()always picks the light side. Add:root { color-scheme: light dark; }. See Light and Dark. - My CSS doesn't override the hue.
-
You set
--hop-*on a parent, like:root. The hue's own value on the element beats an inherited one. Set yours on the element. See Change One End. - The middle letters look wrong.
- Ends far apart on the color wheel, or too much lift, push the middle letters past what an sRGB screen can show, and they get clipped. Lower the lift or bring the ends closer, then check the middle letters. See Lift.
-
React warns about
hops. -
Server rendering. The server's HTML has no
hops, and the element writes it before React 18 hydrates, so the development build printsWarning: Extra attributes from the server: hops. It's harmless, and the colors stay correct. If your test runner fails on console errors, let that one line through. See React. - Drift doesn't move.
-
-
drift.cssisn't loaded. Load it (see Load It). -
The element is in another component's shadow root. Load
drift.csson the page and in that root. - Reduced motion is on. Drift holds still on purpose, and under forced colors and increased contrast too.
-
- Colors don't follow my theme switch.
-
--hop-*set on:rootin aprefers-color-schemequery, while your theme switches on a class. Set them under that class too.
Still stuck? Ask in Issues (see Support).
Inspiration
It started with the per-letter wordmark on
- Why stepped
- One flat color per letter is a choice you can check. Every letter's contrast is a number.
- Why now
- The CSS Custom Highlight API ships in Chrome, Firefox, and Safari. That's what lets the text stay text.
How It Compares
Against the two usual ways to do this:
| Check | Gradient text | Per-letter spans | |
|---|---|---|---|
| Select and copy as plain text |
|
|
|
| Find in page |
|
|
|
| Screen reader reads words |
|
|
|
| Kerning kept |
|
|
|
| Forced colors mode | Invisible or plain | Plain text | Plain text |
| Contrast checkable per letter |
|
|
|
| Extra markup |
|
One span per letter | One tag |
| Size | 0 | 0 plus your JS | under 2.5 KB |
Support
Bugs, ideas, and questions go in GitHub Issues.
- Bug reports
- A report helps most with your browser and its version, the markup, the colors, the page background, and a screenshot.
- Big changes
- Changes to the API, the formula, or the seven hues start as an issue, so you get an answer before you write code. See CONTRIBUTING.
Project
Colophon
What these pages are made of. The element needs none of it. Where the idea came from is under Inspiration.
- Type
- Onest for the words. Departure Mono for readouts and labels. Commit Mono for code. The samples in the Wild add Fraunces and Bricolage Grotesque.
- Studio fonts
- DM Sans, Instrument Sans, Funnel Sans, Host Grotesk, Golos Text, Rethink Sans, Geist, and Mona Sans. Every font here is under the SIL Open Font License and served from this site. No font service.
- Icons
- Font Awesome's pixel family, by way of Web Awesome, which also does the page shell and the controls.
- Code samples
- microlighter
- Built with
- esbuild bundles the element. Prettier formats. The tests run in Playwright, through Node's own test runner.
- Hosted on
- GitHub Pages, from the public repo.
- The name
- A play on hip hop. That's why the samples are song titles and the Studio looks like a mixing board.
- Made by
- Brian Talbot.