Skip to content

Repository files navigation

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.

LIVE DEMO

Install

npm install @lostisworld/svelte-fliptext
pnpm add @lostisworld/svelte-fliptext

Peer 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 dev

src/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

Releases

Packages

Contributors

Languages