Glass is CSS on the element, so when a label grows, the glass grows in the same frame, highlight and corners included. A glass group redraws in the same frame its child resizes.
Text
Labels that change in place, with letters sliding to their new positions, and numbers whose digits roll, like SwiftUI's content transitions. This is torph, not a Fluid Kit package; this page shows how it works with the kit.
On this page
Get started
There’s already a great library for animated text: torph by Lochie Axon. It has no dependencies and comes with React, Vue and Svelte components. It animates text letter by letter (shared letters slide to their new positions) and numbers digit by digit. Fluid Kit doesn’t wrap it: use torph directly, and the kit’s components follow the size changes it causes.
Install.
pnpm add torphAnimate an element’s text. Pass the element, then the new text.
import { TextMorph } from "torph"; const label = new TextMorph({ element: document.querySelector(".label")!, // Use a spring, like the rest of the kit ease: { stiffness: 260, damping: 30 }, }); label.update("Following");
Install.
pnpm add torphAnimate a label. torph has its own React component: its children are the text.
import { TextMorph } from "torph/react"; export function FollowButton() { const [on, setOn] = useState(false); return ( <button className="lb lb-interactive" onClick={() => setOn(!on)}> <TextMorph ease={{ stiffness: 260, damping: 30 }}>{on ? "Following" : "Follow"}</TextMorph> </button> ); }
Install.
pnpm add torphAnimate a label. torph has its own Vue component:
textis the text.<script setup lang="ts"> import { ref } from "vue"; import { TextMorph } from "torph/vue"; const on = ref(false); </script> <template> <button class="lb lb-interactive" @click="on = !on"> <TextMorph :text="on ? 'Following' : 'Follow'" :ease="{ stiffness: 260, damping: 30 }" /> </button> </template>
Install.
pnpm add torphAnimate a label. torph has its own Svelte component:
textis the text.<script lang="ts"> import { TextMorph } from "torph/svelte"; let on = $state(false); </script> <button class="lb lb-interactive" onclick={() => (on = !on)}> <TextMorph text={on ? "Following" : "Follow"} ease={{ stiffness: 260, damping: 30 }} /> </button>
For prices, percentages and counters where digits spin through intermediate values, try NumberFlow. It’s for numbers only, formatted with Intl.
Examples
On glass
When the label changes, torph animates its width along with the letters. The glass around it is CSS, so it follows automatically, including press effects. Try Follow, or Get the app.
<button class="pill lb lb-interactive"><span class="label">Follow</span></button>import "@fluidkit/blur/style.css";
import { TextMorph } from "torph";
const button = document.querySelector<HTMLButtonElement>(".pill")!;
const label = new TextMorph({ element: button.querySelector(".label")!, ease: { stiffness: 260, damping: 30 } });
button.addEventListener("click", () => {
const on = button.getAttribute("aria-pressed") !== "true";
button.setAttribute("aria-pressed", String(on));
label.update(on ? "Following" : "Follow");
});import "@fluidkit/blur/style.css";
import { useState } from "react";
import { Glass } from "@fluidkit/blur/react";
import { TextMorph } from "torph/react";
export function FollowButton() {
const [on, setOn] = useState(false);
return (
<Glass interactive className="pill" render={<button aria-pressed={on} onClick={() => setOn(!on)} />}>
<TextMorph ease={{ stiffness: 260, damping: 30 }}>{on ? "Following" : "Follow"}</TextMorph>
</Glass>
);
}<script setup lang="ts">
import "@fluidkit/blur/style.css";
import { ref } from "vue";
import { Glass } from "@fluidkit/blur/vue";
import { TextMorph } from "torph/vue";
const on = ref(false);
</script>
<template>
<Glass interactive class="pill" as-child>
<button :aria-pressed="on" @click="on = !on">
<TextMorph :text="on ? 'Following' : 'Follow'" :ease="{ stiffness: 260, damping: 30 }" />
</button>
</Glass>
</template><script lang="ts">
import "@fluidkit/blur/style.css";
import { Glass } from "@fluidkit/blur/svelte";
import { TextMorph } from "torph/svelte";
let on = $state(false);
</script>
<Glass interactive class="pill">
{#snippet child({ props })}
<button {...props} aria-pressed={on} onclick={() => (on = !on)}>
<TextMorph text={on ? "Following" : "Follow"} ease={{ stiffness: 260, damping: 30 }} />
</button>
{/snippet}
</Glass>Tabs with counts
Tabs sized to their labels, each with a count. When new mail arrives, the count rolls, the tab grows, and the Indicator thumb on it follows exactly, frame by frame.
import { createIndicator } from "@fluidkit/indicator";
import { TextMorph } from "torph";
const thumb = createIndicator({ container: tabs, indicator, trigger: "manual", rest: tabs.children[1] });
const unread = new TextMorph({ element: tabs.querySelector(".unread")! });
// The tab grows with its label, and the thumb follows
unread.update(`Unread ${count}`);import { useState } from "react";
import { Indicator } from "@fluidkit/indicator/react";
import { TextMorph } from "torph/react";
export function MailTabs() {
const [tab, setTab] = useState("unread");
const [unread, setUnread] = useState(3);
return (
<>
<Indicator.Root value={tab} onValueChange={setTab} trigger="press" className="tabs" role="tablist">
<Indicator.Thumb className="tabs__thumb" />
<Indicator.Item value="all" role="tab">All</Indicator.Item>
{/* The tab grows with its label, and the thumb follows */}
<Indicator.Item value="unread" role="tab">
<TextMorph>{`Unread ${unread}`}</TextMorph>
</Indicator.Item>
</Indicator.Root>
<button onClick={() => setUnread(unread + 1)}>New mail</button>
</>
);
}<script setup lang="ts">
import { ref } from "vue";
import { Indicator } from "@fluidkit/indicator/vue";
import { TextMorph } from "torph/vue";
const tab = ref("unread");
const unread = ref(3);
</script>
<template>
<Indicator.Root v-model="tab" trigger="press" class="tabs" role="tablist">
<Indicator.Thumb class="tabs__thumb" />
<Indicator.Item value="all" role="tab">All</Indicator.Item>
<!-- The tab grows with its label, and the thumb follows -->
<Indicator.Item value="unread" role="tab">
<TextMorph :text="`Unread ${unread}`" />
</Indicator.Item>
</Indicator.Root>
<button @click="unread++">New mail</button>
</template><script lang="ts">
import { Indicator } from "@fluidkit/indicator/svelte";
import { TextMorph } from "torph/svelte";
let tab = $state("unread");
let unread = $state(3);
</script>
<Indicator.Root bind:value={tab} trigger="press" class="tabs" role="tablist">
<Indicator.Thumb class="tabs__thumb" />
<Indicator.Item value="all" role="tab">All</Indicator.Item>
<!-- The tab grows with its label, and the thumb follows -->
<Indicator.Item value="unread" role="tab">
<TextMorph text={`Unread ${unread}`} />
</Indicator.Item>
</Indicator.Root>
<button onclick={() => unread++}>New mail</button>A total
A quantity and total in a glass card. Digits roll up when the number grows and down when it shrinks. Only the digits that change move; $ and . stay put.
const qty = new TextMorph({ element: qtyEl });
const total = new TextMorph({ element: totalEl });
function set(n: number) {
qty.update(String(n));
total.update(`$${(n * 12.5).toFixed(2)}`);
}import { useState } from "react";
import { TextMorph } from "torph/react";
export function Cart() {
const [n, setN] = useState(1);
return (
<div className="cart lb">
<button aria-label="One less" onClick={() => setN(Math.max(0, n - 1))}>−</button>
<TextMorph className="qty">{String(n)}</TextMorph>
<button aria-label="One more" onClick={() => setN(n + 1)}>+</button>
<p>Total <TextMorph>{`$${(n * 12.5).toFixed(2)}`}</TextMorph></p>
</div>
);
}<script setup lang="ts">
import { ref } from "vue";
import { TextMorph } from "torph/vue";
const n = ref(1);
</script>
<template>
<div class="cart lb">
<button aria-label="One less" @click="n = Math.max(0, n - 1)">−</button>
<TextMorph class="qty" :text="String(n)" />
<button aria-label="One more" @click="n++">+</button>
<p>Total <TextMorph :text="`$${(n * 12.5).toFixed(2)}`" /></p>
</div>
</template><script lang="ts">
import { TextMorph } from "torph/svelte";
let n = $state(1);
</script>
<div class="cart lb">
<button aria-label="One less" onclick={() => (n = Math.max(0, n - 1))}>−</button>
<TextMorph class="qty" text={String(n)} />
<button aria-label="One more" onclick={() => n++}>+</button>
<p>Total <TextMorph text={`$${(n * 12.5).toFixed(2)}`} /></p>
</div>With the kit
Notes
- torph changes the element’s children while animating, so give it its own element, like a
<span>inside the button, not the button itself. - It respects reduced motion by default: the text changes instantly.
- For an editable value, pass the caret position to
update()so a typed digit is inserted instead of all digits shifting. See torph’s README.