mirror of
https://github.com/tabler/tabler.git
synced 2026-10-12 05:06:55 +04:00
Add the text shimmer component for "Thinking…" labels (#3152)
This commit is contained in:
1 parent
6c46d55336
commit
744f9fecf5
13 files changed
+409
-1
No files matched your search
@@ -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.
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 }]
|
||||
@@ -330,6 +330,10 @@
|
||||
"title": "Tag",
|
||||
"url": "/ui/components/tag"
|
||||
},
|
||||
{
|
||||
"title": "Text shimmer",
|
||||
"url": "/ui/components/text-shimmer"
|
||||
},
|
||||
{
|
||||
"title": "Timeline",
|
||||
"url": "/ui/components/timeline"
|
||||
|
||||
@@ -272,6 +272,10 @@
|
||||
"url": "tags.html",
|
||||
"title": "Tags"
|
||||
},
|
||||
"text-shimmer": {
|
||||
"title": "Text shimmer",
|
||||
"url": "text-shimmer.html"
|
||||
},
|
||||
"toasts": {
|
||||
"title": "Toasts",
|
||||
"url": "toasts.html"
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user