@lostisworld/svelte-fliptext
v0.0.1
Published
A per-letter "flip" text effect for Svelte 5, styled with Tailwind CSS v4.
Downloads
196
Maintainers
Readme
svelte-fliptext
A per-letter "flip" text effect for Svelte 5, styled with Tailwind CSS v4.
Each letter renders twice — once visible, once as a clipped duplicate — and slides into place on hover (or on demand) using CSS transitions. No JS animation loop.
Install
npm install @lostisworld/svelte-fliptextpnpm add @lostisworld/svelte-fliptextPeer dependencies: svelte ^5.0.0 and tailwindcss ^4.0.0. You need both already set up in your project.
Tailwind v4 setup
Tailwind v4's automatic source detection skips node_modules by default, so the utility classes used inside this package won't be generated unless you point Tailwind at it explicitly. Add a @source directive to your main CSS file:
@import 'tailwindcss';
@source '../node_modules/@lostisworld/svelte-fliptext';(Adjust the relative path to wherever your CSS file lives relative to node_modules.) Skipping this step means the component renders as plain, unanimated text.
Usage
<script>
import { Fliptext } from '@lostisworld/svelte-fliptext';
</script>
<Fliptext text="Hover me" class="text-3xl font-bold" />Direction
<Fliptext text="Right" direction="right" />direction accepts 'up' (default), 'down', 'left', or 'right'. Each letter has its own clip window (an outer wrapper with overflow-clip that never moves) and an inner wrapper that slides + carries the duplicate — that per-letter isolation is what keeps left/right from visually colliding with neighboring letters.
Manual trigger
By default the flip fires on :hover. Set trigger="manual" and drive it yourself via the bindable active prop — useful for click, scroll-into-view, or timed triggers:
<script>
let active = $state(false);
</script>
<button onclick={() => (active = !active)}>
<Fliptext text="Click me" trigger="manual" {active} />
</button>trigger="manual" also sets data-state="active"/"inactive" on the root element, so you can style the active state directly without any extra local state:
<Fliptext
text="Active"
trigger="manual"
{active}
class="rounded-lg px-3 py-1 transition-colors data-[state=active]:bg-indigo-500 data-[state=active]:text-white"
/>Scroll-triggered (or any custom trigger)
ref (bindable) exposes the root element, so you can wire up anything you like — an IntersectionObserver, a timer, another component's state — and drive it into trigger="manual" + active:
<script>
let ref = $state(null);
let active = $state(false);
$effect(() => {
if (!ref) return;
const observer = new IntersectionObserver(([entry]) => (active = entry.isIntersecting), {
threshold: 0.6
});
observer.observe(ref);
return () => observer.disconnect();
});
</script>
<Fliptext bind:ref text="Scroll to reveal" trigger="manual" {active} />Disabling the effect
<Fliptext text="Plain text" animated={false} />Renders the text as-is with none of the per-letter markup or CSS.
Text or background color on hover
No special prop needed — the component is a normal Tailwind element, so hover:/transition-colors on class just works. Color inherits down to the letters automatically:
<Fliptext text="Colorful" class="transition-colors hover:text-pink-400" />
<Fliptext
text="Highlight"
class="rounded-lg px-3 py-1 transition-colors hover:bg-amber-400 hover:text-zinc-900"
/>Per-letter styling
letterClasses accepts a class value, or a function for per-letter control:
<Fliptext text="Highlight" letterClasses={(letter, i) => (i === 0 ? 'text-red-500' : '')} />Colored pseudo-element (color that only shows once flipped)
Each letter's duplicate is a real ::after pseudo-element, so it takes Tailwind's after: variant. That lets the letter and its flipped-in duplicate have different colors — the color only appears once the flip has actually happened:
<Fliptext text="Reveal" letterClasses="text-white after:text-pink-500" />If you build the class per-letter dynamically, keep the full class string literal somewhere Tailwind can scan it — a runtime-assembled string like `after:text-${color}` won't generate any CSS. Use a lookup array of complete class names instead:
<script>
const rainbowAfter = ['after:text-red-400', 'after:text-orange-400', 'after:text-sky-400'];
</script>
<Fliptext
text="Reveal"
letterClasses={(_, i) => `text-white ${rainbowAfter[i % rainbowAfter.length]}`}
/>Element ref
<script>
let ref = $state(null);
</script>
<Fliptext text="Hello" bind:ref />Other elements, extra attributes
as renders the root as any non-void element (FliptextElement: p, span, div, h1–h6, a, button, li, td, ...). Anything not listed as a prop — href, onclick, id, ARIA attributes, etc. — is spread onto that root element as-is:
<Fliptext as="a" href="/pricing" text="See pricing" class="text-sky-400 underline" />
<Fliptext as="button" text="Submit" onclick={handleSubmit} class="rounded bg-sky-500 px-4 py-2" />Props
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| text | string | — | Required. The text to render. |
| as | FliptextElement | 'span' | The root element tag. Restricted to non-void elements (p, span, div, h1-h6, ...) since letters render as child <span>s. |
| class | ClassValue | — | Classes for the root element. |
| direction | 'up' \| 'down' \| 'left' \| 'right' | 'up' | Flip axis. |
| trigger | 'hover' \| 'manual' | 'hover' | How the flip is triggered. |
| active | boolean (bindable) | false | Flip state when trigger="manual". Also reflected as data-state="active"/"inactive" on the root. |
| animated | boolean | true | Set false to render plain text with no per-letter markup. |
| ref | HTMLElement \| null (bindable) | — | The rendered root element. |
| letterClasses | ClassValue \| ((letter: string, index: number) => ClassValue) | — | Classes for each letter, or a per-letter function. Supports the after: variant for styling the flipped-in duplicate. |
| flipTransition | { delay?, duration?, easing? } | — | delay (ms, multiplied per letter index), duration, easing. |
Any other prop (href, onclick, id, aria-*, ...) is spread onto the root element.
prefers-reduced-motion: reduce is respected automatically — no prop needed.
The root element ships with self-start, so it shrink-wraps to its text even when dropped directly into a flex/grid container that would otherwise stretch it to fill the cross axis.
Developing
npm install
npm run devsrc/routes is a showcase app for local development covering every pattern above; src/lib/svelte-fliptext is the published package.
npm run build # builds the package to dist/ and runs publint
npm run check # type-checks