# Outline Fill Text

> A heading drawn as an outline that floods with solid colour from left to right, on hover or as it scrolls into view.

Category: Text · Free · Updated 2026-10-26 · Live demo: https://www.motionkitui.com/components/outline-fill-text

## When to use it

Display headings on portfolios, agency sites and launch pages, and hover states on large navigation or project links.

## How it works

The visible heading is real text drawn with a transparent fill and a -webkit-text-stroke outline. Exactly on top sits a filled copy, hidden from assistive tech, clipped with clip-path: inset() from the right. Animating that inset from 100% to 0% pours the colour across the word from left to right, with an ease that starts and finishes gently. In hover mode it drains back when the pointer leaves; in view mode it fills once.

## Installation

```bash
npx shadcn@latest add https://www.motionkitui.com/r/outline-fill-text.json
```

Or copy `components/motionkit/outline-fill-text.tsx` below. Requires react, motion and Tailwind CSS v4. Free for personal projects; commercial use needs MotionKit Plus (https://www.motionkitui.com/pricing).

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` | `string` |  | The heading. |
| `trigger` | `"hover" \| "view"` | `"hover"` | Fill on hover, or once when it scrolls into view. |
| `color` | `string` | `"#F6F1E8"` | Fill colour. |
| `stroke` | `string` | `"#5FAEB4"` | Outline colour. |
| `strokeWidth` | `number` | `1.5` | Outline width in pixels. |
| `duration` | `number` | `0.7` | Seconds for the fill. |
| `as` | `ElementType` | `"h2"` | The element to render. |

## Accessibility

- **Keyboard: Not applicable.** A heading, nothing to focus. Wrap it in a link to make it one, and the fill follows hover.
- **Screen readers: Pass.** Read once as ordinary heading text; the filled copy is hidden from assistive tech.
- **Reduced motion: Pass.** With reduced motion on, the heading is shown filled straight away.
- **Colour contrast: Partial.** Filled text measures 17:1. The outline alone is thin, so it suits large display headings, where its 7.5:1 stroke stays legible.

## Source

```tsx
"use client";

import { useRef, useState, type ElementType } from "react";
import { motion, useInView, useReducedMotion } from "motion/react";

type OutlineFillTextProps = {
  text: string;
  /** "hover" fills while the pointer or focus is on it; "view" fills once when it scrolls into view. */
  trigger?: "hover" | "view";
  /** Colour of the fill. */
  color?: string;
  /** Colour of the outline. */
  stroke?: string;
  /** Outline width in pixels. */
  strokeWidth?: number;
  /** Seconds for the fill to cross the word. */
  duration?: number;
  as?: ElementType;
  className?: string;
};

/**
 * A heading drawn as an outline that floods with solid colour from left to right, on hover or when it
 * comes into view, with a bright leading edge as the colour pours in.
 */
export function OutlineFillText({
  text,
  trigger = "hover",
  color = "#F6F1E8",
  stroke = "#5FAEB4",
  strokeWidth = 1.5,
  duration = 0.7,
  as: Tag = "h2",
  className = "",
}: OutlineFillTextProps) {
  const reduceMotion = useReducedMotion();
  const ref = useRef<HTMLSpanElement>(null);
  const inView = useInView(ref, { once: true, amount: 0.6 });
  const [hovered, setHovered] = useState(false);
  const filled = reduceMotion || (trigger === "view" ? inView : hovered);

  return (
    <Tag
      className={`relative inline-block ${className}`}
      onPointerEnter={trigger === "hover" ? () => setHovered(true) : undefined}
      onPointerLeave={trigger === "hover" ? () => setHovered(false) : undefined}
    >
      {/* the outline is the real, readable text; the filled copy above it is decoration */}
      <span ref={ref} style={{ color: "transparent", WebkitTextStroke: `${strokeWidth}px ${stroke}` }}>
        {text}
      </span>
      <motion.span
        aria-hidden
        className="pointer-events-none absolute inset-0"
        style={{ color, WebkitTextStroke: `${strokeWidth}px ${color}` }}
        initial={false}
        animate={{ clipPath: filled ? "inset(0 0% 0 0)" : "inset(0 100% 0 0)" }}
        transition={{ duration: reduceMotion ? 0 : duration, ease: [0.65, 0, 0.35, 1] }}
      >
        {text}
      </motion.span>
    </Tag>
  );
}
```
