Styling the highlight
Out of the box a spoken word is lit, the words behind it are washed, and the gap between two lit words is lit as well, so the highlight reads as one band rather than a row of boxes. There are three ways to change that, and they layer: CSS variables, then your own classes, then rendering the word yourself.
The page you are reading is the first two of them. The band under these words is the site's own colour, and the player at the top of this column is wearing the site's own classes.
The three variables
The quickest change is a colour. <SpokenText> paints its built-in highlight
from three custom properties, so setting them anywhere above the text is
enough:
--spoken-text-past
--spoken-text-current
--spoken-text-accentpast is the wash behind everything already said, current is the word being
said, and accent is the rule under it. This site sets all three in one place,
once, for both themes:
:root {
--spoken-text-past: hsl(202 84% 70% / 0.42);
--spoken-text-current: hsl(202 84% 70%);
--spoken-text-accent: hsl(209 68% 37%);
}
@media (prefers-color-scheme: dark) {
:root {
--spoken-text-past: hsl(200 78% 62% / 0.16);
--spoken-text-current: hsl(200 78% 62% / 0.40);
--spoken-text-accent: hsl(203 86% 74%);
}
}Light text sits on a dark ground, so the dark values are a wash rather than a block. Nothing else about the highlight changes.
classNames
For anything past a colour, take the styling over. classNames puts your class
on each part of a word, and supplying one drops the built-in look for that
slot, so your CSS is not fighting an inline style.
- word
Every word, in every state. The place for the cursor, the transition and the hover.
- past
Added to a word already spoken.
- current
Added to the word being spoken.
- future
Added to a word not yet spoken.
- separator
The whitespace between two words. It carries the same state as the word after it, which is what turns a row of boxes into one continuous band.
<SpokenText
classNames={{
word: "spoken-word",
past: "spoken-word-past",
current: "spoken-word-current",
}}
>
Any text you like.
</SpokenText>.spoken-word {
cursor: pointer;
transition: background-color 160ms ease;
}
.spoken-word-past {
background-color: var(--spoken-text-past);
}
.spoken-word-current {
background-color: var(--spoken-text-current);
box-shadow: inset 0 -0.12em 0 var(--spoken-text-accent);
border-radius: 2px;
}One warning worth the ink: do not pad or round the words themselves. The gaps between them are painted too, and a box that overlapped one would double the colour into a seam at every junction.
The data attributes
Every word carries its state as an attribute, whether or not you pass any classes:
- data-spoken-state
Where the word sits relative to the playhead. The separator spans carry it too.
- data-spoken-index
The word's position in the whole document, not in its block.
So the entire highlight can be written in plain CSS, with nothing passed to the component at all:
[data-spoken-state="past"] {
background: var(--spoken-text-past);
}
[data-spoken-state="current"] {
background: var(--spoken-text-current);
}data-spoken-index is what to reach for when one word in particular matters:
[data-spoken-index="0"] is the first word of the document.
renderWord
When a class is not enough, render the word yourself. Whitespace is still inserted for you, so the band keeps working.
<SpokenText
renderWord={(word) => (
<span
className={word.state === "current" ? "lit" : undefined}
data-spoken-state={word.state}
data-spoken-index={word.index}
>
{word.text}
</span>
)}
>
Any text you like.
</SpokenText>You get a DisplayWord: the text exactly as it was written, its index, its
state, whether it is seekable, and its start and end in seconds when it
has been timed. Taking rendering over means taking the click over too: the
built-in seek-on-click lives on the built-in word.
This site uses renderWord for one thing only. A passage that mentions a
function by name needs that name in the mono face, and a class cannot reach a
single word, so those passages render their own spans and set the smaller face
on a span inside the word rather than on the word itself. The highlight is
painted on the word box, and a box in a smaller face would sit a little lower
and a little shorter than the prose either side of it.
The player
<Player> takes the same kind of map, one class per part, with the same rule:
supplying one replaces the built-in look for that part.
- root
The wrapper. Replaces the column layout, so it owns the gap between the row and the status line.
- button
The play and pause button. Style
:disabledyourself: nothing is pressable until the first block has words in it.- track
The scrubber's rail.
- elapsed
The played portion of the rail.
- thumb
The dot marking the playhead.
- time
Elapsed and total. The total is dimmed and marked with a tilde while blocks are still loading.
- status
The loading and error line.
showStatus={false}removes it instead.
The player above this page is that map with two entries in it:
<Player
className="font-mono"
classNames={{ root: "docs-player", button: "docs-player-button" }}
/>className goes on the wrapper alongside whatever classNames.root says, so
the two can be used together: one for the layout, the other for the type.