From 744f9fecf56529df2c66dea4837758b0a4dffa35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Kuna?= <1282324+codecalm@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:26:59 +0200 Subject: [PATCH] =?UTF-8?q?Add=20the=20text=20shimmer=20component=20for=20?= =?UTF-8?q?"Thinking=E2=80=A6"=20labels=20(#3152)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/text-shimmer.md | 7 + core/scss/_core.scss | 1 + core/scss/_variables.scss | 14 ++ core/scss/ui/_text-shimmer.scss | 92 ++++++++++++ docs/content/ui/components/text-shimmer.mdx | 79 +++++++++++ preview/pages/text-shimmer.astro | 134 ++++++++++++++++++ screenshots/.build/record.ts | 2 +- .../pages/text-shimmer-animation.astro | 15 ++ screenshots/pages/text-shimmer.astro | 35 +++++ screenshots/steps/text-shimmer-animation.json | 1 + shared/data/docs.json | 4 + shared/data/menu.json | 4 + shared/ui/TextShimmer.astro | 22 +++ 13 files changed, 409 insertions(+), 1 deletion(-) create mode 100644 .changeset/text-shimmer.md create mode 100644 core/scss/ui/_text-shimmer.scss create mode 100644 docs/content/ui/components/text-shimmer.mdx create mode 100644 preview/pages/text-shimmer.astro create mode 100644 screenshots/pages/text-shimmer-animation.astro create mode 100644 screenshots/pages/text-shimmer.astro create mode 100644 screenshots/steps/text-shimmer-animation.json create mode 100644 shared/ui/TextShimmer.astro diff --git a/.changeset/text-shimmer.md b/.changeset/text-shimmer.md new file mode 100644 index 000000000..98c5514d5 --- /dev/null +++ b/.changeset/text-shimmer.md @@ -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. diff --git a/core/scss/_core.scss b/core/scss/_core.scss index 30a8202d7..03e6aeb15 100644 --- a/core/scss/_core.scss +++ b/core/scss/_core.scss @@ -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'; diff --git a/core/scss/_variables.scss b/core/scss/_variables.scss index 4f976e97d..c3e6084a6 100644 --- a/core/scss/_variables.scss +++ b/core/scss/_variables.scss @@ -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; diff --git a/core/scss/ui/_text-shimmer.scss b/core/scss/ui/_text-shimmer.scss new file mode 100644 index 000000000..ad035353e --- /dev/null +++ b/core/scss/ui/_text-shimmer.scss @@ -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; + } +} diff --git a/docs/content/ui/components/text-shimmer.mdx b/docs/content/ui/components/text-shimmer.mdx new file mode 100644 index 000000000..0e10658ae --- /dev/null +++ b/docs/content/ui/components/text-shimmer.mdx @@ -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. + + +Thinking… + + +## Colors + +The shimmer is a mask, so the text keeps its own color. Combine it with any text color utility. + + +
+ Thinking… + Thinking… + Thinking… + Thinking… +
+
+ +## 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. + + +
+ Thinking… + Generating a reply… +
+
+ +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`. + + +
+ Fast sweep + Slow sweep + Less contrast +
+
+ +## In a chat + +Use the shimmer in place of a reply that has not arrived yet, next to the avatar of the assistant. + + +
+ AI + Reading the file… +
+
+ +## 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. diff --git a/preview/pages/text-shimmer.astro b/preview/pages/text-shimmer.astro new file mode 100644 index 000000000..8a61bc52c --- /dev/null +++ b/preview/pages/text-shimmer.astro @@ -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'] +--- + + + + +
+
+ + + Basic + Add .text-shimmer to a short label that reports work in progress. +
+ {labels.map((label) => {label})} +
+
+
+
+ +
+ + + With a loader + Put an empty .text-shimmer-loader before the label. One element, a small spinner in the text color. +
+ { + labels.map((label) => ( + + {label} + + )) + } + Thinking… + Thinking… +
+
+
+
+ +
+ + + Colors + The text keeps its own color, so any text utility works. +
+ Thinking… + Thinking… + Thinking… + Thinking… + Thinking… +
+
+
+
+ +
+ + + Sizes + The shimmer follows the font size. +
+ Thinking… + Thinking… + Thinking… + Thinking… +
+
+
+
+ +
+ + + Speed + Set --tblr-text-shimmer-duration to change the speed of one sweep. +
+ { + durations.map((duration) => ( +
+ Thinking… + {duration} +
+ )) + } +
+
+
+
+ +
+ + + Contrast + Set --tblr-text-shimmer-opacity to dim the text more or less between sweeps. +
+ { + ['0.2', '0.4', '0.7'].map((opacity) => ( +
+ Thinking… + {opacity} +
+ )) + } +
+
+
+
+ +
+ + + In a chat + A shimmering label stands in for the reply until it arrives. +
+ + Reading the file… +
+
+
+
+
+
diff --git a/screenshots/.build/record.ts b/screenshots/.build/record.ts index a69244f77..4972bf5ba 100644 --- a/screenshots/.build/record.ts +++ b/screenshots/.build/record.ts @@ -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`) } } diff --git a/screenshots/pages/text-shimmer-animation.astro b/screenshots/pages/text-shimmer-animation.astro new file mode 100644 index 000000000..c69c11f93 --- /dev/null +++ b/screenshots/pages/text-shimmer-animation.astro @@ -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' +--- + + +
+ Thinking… +
+
diff --git a/screenshots/pages/text-shimmer.astro b/screenshots/pages/text-shimmer.astro new file mode 100644 index 000000000..1ed552bed --- /dev/null +++ b/screenshots/pages/text-shimmer.astro @@ -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%' }, +] +--- + + + + +
+ { + steps.map((step) => ( + + {step.label} + + )) + } + Thinking… +
+
+
+
diff --git a/screenshots/steps/text-shimmer-animation.json b/screenshots/steps/text-shimmer-animation.json new file mode 100644 index 000000000..f5077dbc6 --- /dev/null +++ b/screenshots/steps/text-shimmer-animation.json @@ -0,0 +1 @@ +[{ "wait": 4800 }] diff --git a/shared/data/docs.json b/shared/data/docs.json index e4b7c9cc8..c42bfaf71 100644 --- a/shared/data/docs.json +++ b/shared/data/docs.json @@ -330,6 +330,10 @@ "title": "Tag", "url": "/ui/components/tag" }, + { + "title": "Text shimmer", + "url": "/ui/components/text-shimmer" + }, { "title": "Timeline", "url": "/ui/components/timeline" diff --git a/shared/data/menu.json b/shared/data/menu.json index f02028f77..82ada0513 100644 --- a/shared/data/menu.json +++ b/shared/data/menu.json @@ -272,6 +272,10 @@ "url": "tags.html", "title": "Tags" }, + "text-shimmer": { + "title": "Text shimmer", + "url": "text-shimmer.html" + }, "toasts": { "title": "Toasts", "url": "toasts.html" diff --git a/shared/ui/TextShimmer.astro b/shared/ui/TextShimmer.astro new file mode 100644 index 000000000..eda1b9a56 --- /dev/null +++ b/shared/ui/TextShimmer.astro @@ -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 +--- + + + {loader &&