Transitions.dev

Home / Transitions

Thinking states

Status line shimmers, then swaps to the next

Open the live demo Install with the Skill

CSS

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; }
}