Tokens
Components read a small set of tokens instead of raw values, so one change restyles the whole library in both themes.
Color
Semantic colors fall back to shadcn's variables when your app defines them. Effect tokens drive highlights, glows and spectrums.
--tl-background--tl-surface--tl-foreground--tl-text-secondary--tl-accent--tl-fx-highlight--tl-fx-glow--tl-fx-spectrum-1--tl-fx-spectrum-2--tl-fx-spectrum-3--tl-fx-spectrum-4Easing
Entrances use ease-out so text arrives fast and lands softly. Things already on screen use a symmetric curve.
Duration, spring and stagger
| duration.instant | 0.1s |
| duration.fast | 0.16s |
| duration.base | 0.24s |
| duration.slow | 0.4s |
| duration.reveal | 0.8s |
| duration.ambient | 2.4s |
| spring.snappy | 0.25s, bounce 0 |
| spring.smooth | 0.4s, bounce 0.1 |
| spring.bouncy | 0.5s, bounce 0.3 |
| spring.gentle | 0.8s, bounce 0.1 |
| stagger.char | 0.02s |
| stagger.word | 0.06s |
| stagger.line | 0.1s |
Interface feedback stays under 300 ms. Text entrances can take longer, but opacity always leads so words are readable before the motion settles.
The stylesheet
tokens.css
/*
* Textlab tokens. Import once in your root layout:
* import "@/components/textlab/tokens.css";
* Colors fall back to shadcn's semantic variables when they exist.
*/
:root {
--tl-background: var(--background, oklch(0.985 0 0));
--tl-surface: oklch(0.965 0 0);
--tl-surface-2: oklch(0.935 0 0);
--tl-foreground: var(--foreground, oklch(0.17 0 0));
--tl-text-secondary: oklch(0.42 0 0);
--tl-text-tertiary: oklch(0.56 0 0);
--tl-border: var(--border, oklch(0.9 0 0));
--tl-accent: oklch(0.52 0.16 148);
--tl-accent-foreground: oklch(0.99 0 0);
--tl-fx-highlight: oklch(0.92 0.15 100 / 0.75);
--tl-fx-glow: oklch(0.7 0.19 145);
--tl-fx-ink: oklch(0.2 0.02 260);
--tl-fx-spectrum-1: oklch(0.7 0.19 30);
--tl-fx-spectrum-2: oklch(0.78 0.16 80);
--tl-fx-spectrum-3: oklch(0.7 0.14 190);
--tl-fx-spectrum-4: oklch(0.6 0.2 290);
--tl-font-sans: var(--font-sans, ui-sans-serif, system-ui, sans-serif);
--tl-font-display: var(--font-display, ui-sans-serif, system-ui, sans-serif);
--tl-font-flex: var(--font-flex, var(--tl-font-sans));
--tl-font-mono: var(--font-mono, ui-monospace, SFMono-Regular, monospace);
--tl-font-script: var(--font-script, "Snell Roundhand", "Brush Script MT", cursive);
--tl-text-display: clamp(2.5rem, 6vw + 1rem, 6rem);
--tl-text-title: clamp(1.75rem, 3vw + 1rem, 3rem);
--tl-leading-tight: 1.05;
--tl-tracking-tight: -0.02em;
--tl-ease-enter: cubic-bezier(0.23, 1, 0.32, 1);
--tl-ease-exit: cubic-bezier(0.23, 1, 0.32, 1);
--tl-ease-standard: cubic-bezier(0.77, 0, 0.175, 1);
--tl-ease-emphasized: cubic-bezier(0.16, 1, 0.3, 1);
--tl-duration-fast: 160ms;
--tl-duration-base: 240ms;
--tl-duration-slow: 400ms;
--tl-duration-reveal: 800ms;
}
.dark,
[data-theme="dark"] {
--tl-background: var(--background, oklch(0.145 0 0));
--tl-surface: oklch(0.185 0 0);
--tl-surface-2: oklch(0.23 0 0);
--tl-foreground: var(--foreground, oklch(0.97 0 0));
--tl-text-secondary: oklch(0.74 0 0);
--tl-text-tertiary: oklch(0.6 0 0);
--tl-border: var(--border, oklch(0.28 0 0));
--tl-accent: oklch(0.86 0.2 132);
--tl-accent-foreground: oklch(0.16 0 0);
--tl-fx-highlight: oklch(0.55 0.13 100 / 0.55);
--tl-fx-glow: oklch(0.86 0.2 132);
--tl-fx-ink: oklch(0.92 0.01 260);
}
/* Parts rendered hidden on the server show anyway if scripts are slow. Hydration removes data-tl-pending. */
[data-tl-pending] [data-tl-part] {
animation: tl-settle 0.4s var(--tl-ease-enter) 2.4s forwards;
}
@keyframes tl-settle {
to {
opacity: 1;
transform: none;
filter: none;
clip-path: none;
--tl-wipe: 200%;
}
}
/* Before hydration the JS reduced-motion branch has not run yet, so the stylesheet shows the final state. */
@media (prefers-reduced-motion: reduce) {
[data-tl-motion="user"] [data-tl-part] {
opacity: 1 !important;
transform: none !important;
filter: none !important;
clip-path: none !important;
mask-image: none !important;
-webkit-mask-image: none !important;
animation: none !important;
}
}