Status line shimmers, then swaps to the next
Namespaced classes and motion tokens you can retune. The reduced-motion guard ships with the snippet.
/* Transitions.dev — Thinking states */
/*
Usage:
<span class="t-think" role="status">
<span class="t-think-sizer" aria-hidden="true">Longest state here</span>
<span class="t-think-text" data-text="Thinking…">Thinking…</span>
</span>
The shimmer runs on ::before (content: attr(data-text),
background-clip: text) while a state holds. JS swaps the line
every --think-hold: exit the outgoing copy (.is-exit) while
the incoming copy enters from below (.is-enter-start →
reflow → release), held back by --think-gap. Keep textContent
and data-text in sync so the shimmer copy always matches the
visible line.
The hidden sizer holds your longest state and is what gives
the box its width: lines are absolutely positioned across
that width, so every state centres in a box that never
resizes mid-swap. Drop the sizer if the line should hug
whatever state is showing, and set text-align: left on
.t-think if the states should share a left edge instead.
*/
:root {
--think-hold: 2000ms;
--think-swap: 150ms;
--think-gap: 50ms;
--think-distance: 8px;
--think-blur: 2px;
--think-shimmer: 2000ms;
--think-base: #7c7c7c;
--think-highlight: #0d0d0d;
--think-ease: ease-in-out;
}
/* Set your type on .t-think: the sizer only reports the right
width if it is set in the same font as the line. */
.t-think { position: relative; display: inline-block; text-align: center; }
.t-think-sizer { display: block; visibility: hidden; white-space: nowrap; }
.t-think-text {
position: absolute;
top: 0;
left: 0;
right: 0;
display: block;
color: var(--think-base);
white-space: nowrap;
transform: translateY(0);
filter: blur(0);
opacity: 1;
transition:
transform var(--think-swap) var(--think-ease),
filter var(--think-swap) var(--think-ease),
opacity var(--think-swap) var(--think-ease);
will-change: transform, filter, opacity;
}
/* Shimmer sweeps the glyphs only (background-clip: text). */
.t-think-text::before {
content: attr(data-text);
position: absolute;
inset: 0;
pointer-events: none;
background-image: linear-gradient(90deg,
transparent 0%, transparent 40%,
var(--think-highlight) 50%,
transparent 60%, transparent 100%);
background-size: 400% 100%;
background-repeat: no-repeat;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
-webkit-text-fill-color: transparent;
animation: t-think-shimmer var(--think-shimmer) linear infinite;
}
@keyframes t-think-shimmer {
0% { background-position: 100% 0; }
100% { background-position: 0% 0; }
}
/* The outgoing line floats over the box so both halves animate. */
.t-think-text.is-exit {
transform: translateY(calc(var(--think-distance) * -1));
filter: blur(var(--think-blur));
opacity: 0;
}
.t-think-text.is-enter-start {
transition: none;
transform: translateY(var(--think-distance));
filter: blur(var(--think-blur));
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.t-think-text { transition: none !important; transform: none !important; filter: none !important; }
.t-think-text::before { display: none !important; }
}