Add the text shimmer component for "Thinking…" labels (#3152)

This commit is contained in:
Paweł Kuna authored and GitHub committed 2026-10-05 23:26:59 +02:00
1 parent 6c46d55336
commit 744f9fecf5
13 files changed
+409 -1

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
---
"@tabler/core": minor
"@tabler/docs": patch
"@tabler/preview": patch
---
Added the `.text-shimmer` class, a highlight that sweeps across a label such as "Thinking…" while a task runs, with an optional `.text-shimmer-loader` spinner before the label.
+1
View File
@@ -92,6 +92,7 @@
@forward 'ui/switch-icon';
@forward 'ui/tables';
@forward 'ui/tags';
@forward 'ui/text-shimmer';
@forward 'ui/toasts';
@forward 'ui/toolbar';
@forward 'ui/tracking';
+14
View File
@@ -1752,6 +1752,20 @@ $signal-bar-inactive-opacity: 0.24 !default;
$signal-max-bars: 5 !default;
// scss-docs-end signal-variables
// scss-docs-start text-shimmer-variables
$text-shimmer-duration: 2s !default;
$text-shimmer-opacity: 0.4 !default;
$text-shimmer-width: 20% !default;
$text-shimmer-fade-duration: 320ms !default;
$text-shimmer-fade-easing: cubic-bezier(0.22, 1, 0.36, 1) !default;
// Sized against 14px text: a 12px ring and 4px before the label
$text-shimmer-loader-size: math.div(12em, 14) !default;
$text-shimmer-loader-border-width: 0.1em !default;
$text-shimmer-loader-margin: math.div(4em, 14) !default;
$text-shimmer-loader-duration: 0.75s !default;
$text-shimmer-loader-opacity: 0.25 !default;
// scss-docs-end text-shimmer-variables
// Steps
// scss-docs-start steps-variables
$steps-border-width: 2px !default;
+92
View File
@@ -0,0 +1,92 @@
@use '../config' as *;
// A highlight that sweeps across the text, for "Thinking…" or "Generating…" labels
.text-shimmer {
// scss-docs-start text-shimmer-css-vars
--text-shimmer-duration: #{$text-shimmer-duration};
--text-shimmer-opacity: #{$text-shimmer-opacity};
--text-shimmer-width: #{$text-shimmer-width};
--text-shimmer-fade-duration: #{$text-shimmer-fade-duration};
--text-shimmer-fade-easing: #{$text-shimmer-fade-easing};
--text-shimmer-loader-size: #{$text-shimmer-loader-size};
--text-shimmer-loader-border-width: #{$text-shimmer-loader-border-width};
--text-shimmer-loader-margin: #{$text-shimmer-loader-margin};
--text-shimmer-loader-duration: #{$text-shimmer-loader-duration};
--text-shimmer-loader-opacity: #{$text-shimmer-loader-opacity};
// scss-docs-end text-shimmer-css-vars
display: inline-block;
// The mask dims the text and lets a band of full opacity move through it, so the text keeps its own color
mask-image: linear-gradient(90deg, rgba(0, 0, 0, var(--text-shimmer-opacity)) calc(50% - var(--text-shimmer-width) / 2), $black 50%, rgba(0, 0, 0, var(--text-shimmer-opacity)) calc(50% + var(--text-shimmer-width) / 2));
mask-size: 300% 100%;
// The label fades in once, then the highlight keeps sweeping
animation:
text-shimmer-in var(--text-shimmer-fade-duration) var(--text-shimmer-fade-easing) both,
text-shimmer var(--text-shimmer-duration) linear infinite;
@media (prefers-reduced-motion: reduce) {
mask-image: none;
animation: none;
}
}
// A small spinner before the label: the element is a 1em box, ::before draws
// a faint ring in the text color and ::after the bright arc that turns on top
// of it. It sits inside the mask, so it dims and lights up with the text
.text-shimmer-loader {
position: relative;
display: inline-block;
width: 1em;
height: 1em;
margin-inline-end: var(--text-shimmer-loader-margin);
vertical-align: -0.125em;
// The ring is --text-shimmer-loader-size wide, centered in the 1em box
&::before,
&::after {
position: absolute;
inset: 0;
width: var(--text-shimmer-loader-size);
height: var(--text-shimmer-loader-size);
margin: auto;
content: '';
border: var(--text-shimmer-loader-border-width) solid;
border-radius: 50%;
}
&::before {
border-color: currentColor;
opacity: var(--text-shimmer-loader-opacity);
}
&::after {
border-color: currentColor transparent transparent;
animation: rotate-360 var(--text-shimmer-loader-duration) linear infinite;
@media (prefers-reduced-motion: reduce) {
animation-duration: calc(var(--text-shimmer-loader-duration) * 2);
}
}
}
@keyframes text-shimmer-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
// The highlight travels in the reading direction: --dir flips the start and end in RTL
@keyframes text-shimmer {
from {
/* rtl:ignore */
mask-position: calc(50% + var(--dir) * 50%) 0;
}
to {
/* rtl:ignore */
mask-position: calc(50% - var(--dir) * 50%) 0;
}
}
@@ -0,0 +1,79 @@
---
title: Text shimmer
summary: A highlight that sweeps across a short label while an agent thinks or a task runs.
description: Show that something is in progress with the text shimmer - a light band that moves across a label such as "Thinking…" until the result arrives.
related: [/ui/components/spinner, /ui/components/placeholder, /ui/components/chat]
source: core/scss/ui/_text-shimmer.scss
css-vars: text-shimmer
classnames:
component:
- class: text-shimmer
desc: Dims the text and sweeps a highlight across it
part:
- class: text-shimmer-loader
desc: A small spinner before the label
---
import Example from '@components/Example.astro';
## Basic example
Add `.text-shimmer` to a short label that reports work in progress. The label fades in, then the text is dimmed and a band of full color moves across it from start to end.
<Example centered>
<span class="text-shimmer">Thinking…</span>
</Example>
## Colors
The shimmer is a mask, so the text keeps its own color. Combine it with any text color utility.
<Example centered>
<div class="d-flex flex-column gap-2">
<span class="text-shimmer">Thinking…</span>
<span class="text-shimmer text-secondary">Thinking…</span>
<span class="text-shimmer text-primary">Thinking…</span>
<span class="text-shimmer text-green">Thinking…</span>
</div>
</Example>
## With a loader
Put an empty `.text-shimmer-loader` before the label when the text alone is too quiet. It is a small spinner drawn by the element alone. It sits inside the shimmer, so it takes the color of the text and dims with it.
<Example centered>
<div class="d-flex flex-column gap-2">
<span class="text-shimmer" role="status"><span class="text-shimmer-loader" aria-hidden="true"></span>Thinking…</span>
<span class="text-shimmer text-primary" role="status"><span class="text-shimmer-loader" aria-hidden="true"></span>Generating a reply…</span>
</div>
</Example>
The loader is a `1em` box, so it follows the font size. Set `--tblr-text-shimmer-loader-size` and `--tblr-text-shimmer-loader-border-width` to resize the ring inside it, `--tblr-text-shimmer-loader-duration` to change its speed, `--tblr-text-shimmer-loader-opacity` to change how faint the ring behind the arc is, and `--tblr-text-shimmer-loader-margin` to change the space before the label.
## Speed and contrast
Set `--tblr-text-shimmer-duration` to change how long one sweep takes, and `--tblr-text-shimmer-opacity` to change how much the text is dimmed between sweeps. The fade-in is `320ms` by default; change it with `--tblr-text-shimmer-fade-duration`.
<Example centered>
<div class="d-flex flex-column gap-2">
<span class="text-shimmer" style="--tblr-text-shimmer-duration: 1s">Fast sweep</span>
<span class="text-shimmer" style="--tblr-text-shimmer-duration: 4s">Slow sweep</span>
<span class="text-shimmer" style="--tblr-text-shimmer-opacity: 0.7">Less contrast</span>
</div>
</Example>
## In a chat
Use the shimmer in place of a reply that has not arrived yet, next to the avatar of the assistant.
<Example centered>
<div class="d-flex align-items-center gap-2">
<span class="avatar avatar-xs">AI</span>
<span class="text-shimmer text-secondary" role="status"><span class="text-shimmer-loader" aria-hidden="true"></span>Reading the file…</span>
</div>
</Example>
## Accessibility
- The animation is only a hint. The label itself has to say what is going on, for example "Thinking…" or "Uploading 3 files…".
- Add `role="status"` when the label appears while the user waits, so screen readers announce it and any change to it.
- When the user prefers reduced motion, the sweep stops and the text shows in its full color.
+134
View File
@@ -0,0 +1,134 @@
---
import DefaultLayout from '@shared/layouts/DefaultLayout.astro'
import Card from '@ui/Card.astro'
import CardBody from '@ui/CardBody.astro'
import CardTitle from '@ui/CardTitle.astro'
import CardSubtitle from '@ui/CardSubtitle.astro'
import DocsLink from '@ui/DocsLink.astro'
import TextShimmer from '@ui/TextShimmer.astro'
import Avatar from '@ui/Avatar.astro'
const labels = ['Thinking…', 'Generating a reply…', 'Searching the web…', 'Reading 3 files…']
const durations = ['1s', '2s', '4s']
---
<DefaultLayout title="Text shimmer" pageMenu="base.text-shimmer" description="A highlight that sweeps across a label while an agent thinks or a task runs.">
<DocsLink slot="page-header-actions" path="/ui/components/text-shimmer" />
<div class="row row-cards">
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Basic</CardTitle>
<CardSubtitle>Add <code>.text-shimmer</code> to a short label that reports work in progress.</CardSubtitle>
<div class="d-flex flex-column gap-2">
{labels.map((label) => <TextShimmer live>{label}</TextShimmer>)}
</div>
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>With a loader</CardTitle>
<CardSubtitle>Put an empty <code>.text-shimmer-loader</code> before the label. One element, a small spinner in the text color.</CardSubtitle>
<div class="d-flex flex-column gap-2">
{
labels.map((label) => (
<TextShimmer loader live>
{label}
</TextShimmer>
))
}
<TextShimmer class="text-primary" loader>Thinking…</TextShimmer>
<TextShimmer class="h2 mb-0" loader>Thinking…</TextShimmer>
</div>
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Colors</CardTitle>
<CardSubtitle>The text keeps its own color, so any text utility works.</CardSubtitle>
<div class="d-flex flex-column gap-2">
<TextShimmer>Thinking…</TextShimmer>
<TextShimmer class="text-secondary">Thinking…</TextShimmer>
<TextShimmer class="text-primary">Thinking…</TextShimmer>
<TextShimmer class="text-green">Thinking…</TextShimmer>
<TextShimmer class="text-red">Thinking…</TextShimmer>
</div>
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Sizes</CardTitle>
<CardSubtitle>The shimmer follows the font size.</CardSubtitle>
<div class="d-flex flex-column gap-2">
<TextShimmer class="small">Thinking…</TextShimmer>
<TextShimmer>Thinking…</TextShimmer>
<TextShimmer class="h3 mb-0">Thinking…</TextShimmer>
<TextShimmer class="h1 mb-0">Thinking…</TextShimmer>
</div>
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Speed</CardTitle>
<CardSubtitle>Set <code>--tblr-text-shimmer-duration</code> to change the speed of one sweep.</CardSubtitle>
<div class="d-flex flex-column gap-2">
{
durations.map((duration) => (
<div class="d-flex align-items-center gap-2">
<TextShimmer style={`--tblr-text-shimmer-duration: ${duration}`}>Thinking…</TextShimmer>
<span class="text-secondary small">{duration}</span>
</div>
))
}
</div>
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Contrast</CardTitle>
<CardSubtitle>Set <code>--tblr-text-shimmer-opacity</code> to dim the text more or less between sweeps.</CardSubtitle>
<div class="d-flex flex-column gap-2">
{
['0.2', '0.4', '0.7'].map((opacity) => (
<div class="d-flex align-items-center gap-2">
<TextShimmer style={`--tblr-text-shimmer-opacity: ${opacity}`}>Thinking…</TextShimmer>
<span class="text-secondary small">{opacity}</span>
</div>
))
}
</div>
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>In a chat</CardTitle>
<CardSubtitle>A shimmering label stands in for the reply until it arrives.</CardSubtitle>
<div class="d-flex align-items-center gap-2">
<Avatar size="xs" alt="" placeholder="AI" />
<TextShimmer class="text-secondary" live>Reading the file…</TextShimmer>
</div>
</CardBody>
</Card>
</div>
</div>
</DefaultLayout>
+1 -1
View File
@@ -330,7 +330,7 @@ async function main() {
if (system) {
await encode(system, sampled, ['-c:v', 'libx264', '-preset', 'slow', '-crf', '18', '-pix_fmt', 'yuv420p', '-movflags', '+faststart'], path.join(outDir, `${name}.mp4`))
await encode(system, sampled, ['-vf', `fps=15,scale=iw/2:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=128[p];[s1][p]paletteuse=dither=sierra2_4a`, '-loop', '0'], path.join(outDir, `${name}.gif`))
await encode(system, sampled, ['-vf', `fps=${FPS},scale=iw/2:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=128[p];[s1][p]paletteuse=dither=sierra2_4a`, '-loop', '0'], path.join(outDir, `${name}.gif`))
console.log(` ✓ ${name}.mp4, ${name}.gif`)
}
}
@@ -0,0 +1,15 @@
---
// Text shimmer for the video recorder (`pnpm run record text-shimmer-animation`):
// one "Thinking…" label with its loader, nothing else, zoomed in so the
// fade-in, the spinner and the sweeping highlight fill the frame.
// steps/text-shimmer-animation.json only waits, long enough for the fade-in
// and two full sweeps. Without ?motion the layout freezes the animation.
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
import TextShimmer from '@ui/TextShimmer.astro'
---
<ScreenshotLayout title="Text shimmer animation" zoom={2} showLogo={false}>
<div class="text-center">
<TextShimmer loader live>Thinking…</TextShimmer>
</div>
</ScreenshotLayout>
+35
View File
@@ -0,0 +1,35 @@
---
// Text shimmer in the place it is made for: an assistant that has not
// answered yet. The layout turns animations off, so each label is drawn at a
// fixed point of the sweep with an inline mask-position — the band sits early
// in the first line, in the middle of the second and late in the third, which
// reads as motion on a still picture. The last label carries the loader, and
// the layout zoom keeps the text at its real size while the picture stays
// legible.
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
import Card from '@ui/Card.astro'
import CardBody from '@ui/CardBody.astro'
import TextShimmer from '@ui/TextShimmer.astro'
const steps = [
{ label: 'Searching the web…', position: '80%' },
{ label: 'Reading 3 files…', position: '50%' },
]
---
<ScreenshotLayout title="Text shimmer" columns={2} zoom={2}>
<Card>
<CardBody class="p-4">
<div class="d-flex flex-column gap-3">
{
steps.map((step) => (
<TextShimmer class="text-secondary" style={`--tblr-text-shimmer-width: 40%; mask-position: ${step.position} 0`}>
{step.label}
</TextShimmer>
))
}
<TextShimmer loader style="--tblr-text-shimmer-width: 40%; --tblr-text-shimmer-opacity: 0.3; mask-position: 50% 0">Thinking…</TextShimmer>
</div>
</CardBody>
</Card>
</ScreenshotLayout>
@@ -0,0 +1 @@
[{ "wait": 4800 }]
+4
View File
@@ -330,6 +330,10 @@
"title": "Tag",
"url": "/ui/components/tag"
},
{
"title": "Text shimmer",
"url": "/ui/components/text-shimmer"
},
{
"title": "Timeline",
"url": "/ui/components/timeline"
+4
View File
@@ -272,6 +272,10 @@
"url": "tags.html",
"title": "Tags"
},
"text-shimmer": {
"title": "Text shimmer",
"url": "text-shimmer.html"
},
"toasts": {
"title": "Toasts",
"url": "toasts.html"
+22
View File
@@ -0,0 +1,22 @@
---
// Text shimmer (.text-shimmer): a highlight that sweeps across a short label, such as "Thinking…", while something is in progress.
interface Props {
/** element to render; a `span` fits inline text, a `div` a block */
as?: 'span' | 'div'
/** puts a `.text-shimmer-loader` (a small spinner) before the label, inside the shimmer */
loader?: boolean
/** tells assistive technology that the label reports progress and can change */
live?: boolean
class?: string
/** remaining attributes (id, data-*, …) are forwarded to the root element */
[key: string]: unknown
}
const { as: Tag = 'span', loader = false, live = false, class: className, ...rest }: Props = Astro.props
---
<Tag class:list={['text-shimmer', className]} role={live ? 'status' : undefined} aria-live={live ? 'polite' : undefined} {...rest}>
{loader && <span class="text-shimmer-loader" aria-hidden="true" />}
<slot />
</Tag>