React text with a dynamic speed-like movement effect on hover. Lightweight, customizable and styled with Tailwind CSS.
<SpeedText
className="font-semibold font-serif text-4xl"
direction={direction}
rotation={rotation}
>
Hover me!
</SpeedText>Copy and paste the following code into your project.
import type {
ComponentPropsWithoutRef,
CSSProperties,
ElementType,
} from "react";
import { cn } from "@/lib/utils";
interface SpeedTextOwnProps<T extends ElementType> {
/**
* Controls the animated state externally, useful when the trigger is an
* ancestor that cannot directly hover this element.
*/
active?: boolean;
as?: T;
children: string;
/**
* The direction of the speed link.
* @default "left"
*/
direction?: "left" | "right";
/**
* The rotation of the speed link.
* @default 28
*/
rotation?: number;
}
type SpeedTextProps<T extends ElementType = "span"> = SpeedTextOwnProps<T> &
Omit<ComponentPropsWithoutRef<T>, keyof SpeedTextOwnProps<T>>;
const MAX_STAGGER_MS = 18;
const STAGGER_WINDOW_MS = 162;
const segmenter =
typeof Intl.Segmenter === "function"
? new Intl.Segmenter(undefined, { granularity: "grapheme" })
: null;
const characterTransitionClassName =
"inline-block motion-reduce:transition-none [transition-property:translate,transform,opacity] [transition-duration:600ms,250ms,150ms] [transition-timing-function:cubic-bezier(0.19,1,0.22,1),ease-out,ease-out] [transition-delay:0ms]";
const layers = [
{
characterClassName:
"translate-x-0 skew-x-0 opacity-100 motion-safe:group-hover/speed-link:translate-x-[var(--speed-link-exit-x)] motion-safe:group-focus-visible/speed-link:translate-x-[var(--speed-link-exit-x)] motion-safe:group-data-[active=true]/speed-link:translate-x-[var(--speed-link-exit-x)] motion-safe:group-hover/speed-link:skew-x-[var(--speed-link-skew)] motion-safe:group-focus-visible/speed-link:skew-x-[var(--speed-link-skew)] motion-safe:group-data-[active=true]/speed-link:skew-x-[var(--speed-link-skew)] motion-safe:group-hover/speed-link:opacity-0 motion-safe:group-focus-visible/speed-link:opacity-0 motion-safe:group-data-[active=true]/speed-link:opacity-0 motion-safe:group-hover/speed-link:[transition-delay:calc((var(--char)-1)*var(--speed-link-stagger)+var(--speed-link-out-delay))] motion-safe:group-focus-visible/speed-link:[transition-delay:calc((var(--char)-1)*var(--speed-link-stagger)+var(--speed-link-out-delay))] motion-safe:group-data-[active=true]/speed-link:[transition-delay:calc((var(--char)-1)*var(--speed-link-stagger)+var(--speed-link-out-delay))]",
name: "default",
},
{
characterClassName:
"translate-x-[var(--speed-link-enter-x)] skew-x-[var(--speed-link-skew)] opacity-0 motion-safe:group-hover/speed-link:translate-x-0 motion-safe:group-focus-visible/speed-link:translate-x-0 motion-safe:group-data-[active=true]/speed-link:translate-x-0 motion-safe:group-hover/speed-link:skew-x-0 motion-safe:group-focus-visible/speed-link:skew-x-0 motion-safe:group-data-[active=true]/speed-link:skew-x-0 motion-safe:group-hover/speed-link:opacity-100 motion-safe:group-focus-visible/speed-link:opacity-100 motion-safe:group-data-[active=true]/speed-link:opacity-100 motion-safe:group-hover/speed-link:[transition-delay:calc((var(--char)-1)*var(--speed-link-stagger)+var(--speed-link-in-delay))] motion-safe:group-focus-visible/speed-link:[transition-delay:calc((var(--char)-1)*var(--speed-link-stagger)+var(--speed-link-in-delay))] motion-safe:group-data-[active=true]/speed-link:[transition-delay:calc((var(--char)-1)*var(--speed-link-stagger)+var(--speed-link-in-delay))]",
name: "hover",
},
] as const;
function splitGraphemes(text: string) {
if (!segmenter) {
return Array.from(text);
}
return Array.from(segmenter.segment(text), ({ segment }) => segment);
}
function getStagger(characterCount: number) {
if (characterCount <= 1) {
return 0;
}
return Math.min(MAX_STAGGER_MS, STAGGER_WINDOW_MS / (characterCount - 1));
}
function SpeedText<T extends ElementType = "span">({
active = false,
as,
children,
className,
direction = "left",
rotation = 28,
style,
...props
}: SpeedTextProps<T>) {
const Tag: ElementType = as ?? "span";
const graphemes = splitGraphemes(children);
const SpeedTextStyle: CSSProperties = {
"--speed-link-enter-x": direction === "left" ? "1.35rem" : "-1.35rem",
"--speed-link-exit-x": direction === "left" ? "-1.35rem" : "1.35rem",
"--speed-link-skew": `${direction === "left" ? -rotation : rotation}deg`,
"--speed-link-stagger": `${getStagger(graphemes.length)}ms`,
...style,
};
return (
<Tag
className={cn(
"group/speed-link relative inline-grid flex-none leading-none [--speed-link-in-delay:200ms] [--speed-link-out-delay:50ms]",
className
)}
data-active={active}
style={SpeedTextStyle}
{...props}
>
<span className="sr-only">{children}</span>
<span
aria-hidden="true"
className="pointer-events-none col-start-1 row-start-1 grid"
>
{layers.map((layer) => (
<span
className="col-start-1 row-start-1 whitespace-pre"
data-speed-link-layer={layer.name}
key={layer.name}
>
{graphemes.map((grapheme, index) => (
<span
className={cn(
characterTransitionClassName,
layer.characterClassName
)}
data-speed-link-character=""
key={`${grapheme}-${index}`}
style={{ "--char": index + 1 } as CSSProperties}
>
{grapheme}
</span>
))}
</span>
))}
</span>
</Tag>
);
}
export type { SpeedTextProps };
export { SpeedText };<SpeedText
className="font-semibold font-serif text-4xl"
direction={direction}
rotation={rotation}
>
Hover me!
</SpeedText><SpeedText
className="font-semibold font-serif text-4xl"
direction={direction}
rotation={rotation}
as="a"
href="mailto:email@example.com"
>
email@example.com
</SpeedText>Useful when the trigger is an ancestor that cannot directly hover this element.
const [hover, setHover] = useState(false);
<div onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}>
...
<SpeedText
className="font-semibold font-serif text-4xl"
active={hover}
>
email@example.com
</SpeedText>
</div>You can use aria-attributes to provide any additional information to the component.
<SpeedText aria-labelledby="something-else">
email@example.com
</SpeedText>It accepts all the props of the HTML element it renders as, plus the following:
| Prop | Type | Description |
|---|---|---|
direction | `"left" | "right"` |
rotation | number | The rotation of the speed text. |
as | React.ElementType | The HTML element to render as. Default is span. |