VNL Works
VNL Works
VNL Works
← Components
Download ZIPRegistry JSON
Package: tailwind-motion-kit
motion

Text Reveal (chars / words)

文字/単語を分割して順番に出す。タイトルに効く。

texttypographycss+js
Customize

data-mk-text="chars|words" と --mk-char-step で密度を調整。

A11y

内部で aria-label を保持し、分割spanは aria-hidden にします。

Install

“壊れにくさ”優先のため、依存は増やさずに ファイルをコピーして使う 形式です。

Files

まずは下記ファイルをプロジェクトに配置してください。

required-files.md
- tailwind-motion-kit/css/00_tokens.css (css)
- tailwind-motion-kit/css/06_text.css (css)
- tailwind-motion-kit/js/text-split.js (js)
- tailwind-motion-kit/js/text-reveal.js (js)
- tailwind-motion-kit/js/inview.js (js)
Option A

まずは Bundle を読み込んで、動作を確認するのがおすすめです。

globals.css
/* Bundle: まずはこれが一番簡単 */
@import "./tailwind-motion-kit/css/99_bundle.css";
Option B

必要な CSS だけ読み込みたい場合はこちら。

globals.css
/* 1) CSS: グローバルCSSに追記 */
@import "./tailwind-motion-kit/css/00_tokens.css";
@import "./tailwind-motion-kit/css/06_text.css";
JavaScript

動きやUI制御が必要な場合のみ、初期化を追加します。

init.js
import { initTextReveal } from "./tailwind-motion-kit/js/text-reveal.js";
import { initInView } from "./tailwind-motion-kit/js/inview.js";

initTextReveal();
initInView();

Snippets

Copy & paste
usage.html
<h2 class="mk-text-reveal" data-mk-text="chars" data-mk-inview>Text Reveal</h2>
usage.js
import { initTextReveal } from "./tailwind-motion-kit/js/text-reveal.js";
import { initInView } from "./tailwind-motion-kit/js/inview.js";

initTextReveal();
initInView();

Files

この部品が参照する実ファイルです。

tailwind-motion-kit/css/00_tokens.css
css
tailwind-motion-kit/css/00_tokens.css
/*!
 * Tailwind Motion Kit (mk) - Tokens
 * --------------------------------
 * 目的: 速度・距離・イージング等の“モーション言語”を統一し、
 *       パーツを増やしても品位が崩れない状態を作ります。
 *
 * 使い方:
 *  - :root の変数を上書きするだけで全体の動きを一括調整できます。
 *  - 個別要素は style="" か data-mk-* 属性で上書きできます(JSがある場合)。
 */

:root {
  /* --- イージング(加減速) --- */
  --mk-ease-out: cubic-bezier(0.16, 1, 0.3, 1);
  --mk-ease-in: cubic-bezier(0.32, 0, 0.67, 0);
  --mk-ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
  /* “少しだけ遊び”が欲しい時用(多用は上品さを損ねます) */
  --mk-ease-out-back: cubic-bezier(0.34, 1.56, 0.64, 1);

  /* --- 時間 --- */
  --mk-duration-fast: 160ms;
  --mk-duration-medium: 280ms;
  --mk-duration-slow: 600ms;
  --mk-duration-slower: 900ms;

  /* コンポーネントが参照する“共通デフォルト” */
  --mk-duration: var(--mk-duration-medium);
  --mk-ease: var(--mk-ease-out);
  --mk-delay: 0ms;

  /* --- 移動量 --- */
  --mk-distance-1: 6px;
  --mk-distance-2: 12px;
  --mk-distance-3: 20px;
  --mk-distance: var(--mk-distance-2);

  /* --- ぼかし --- */
  --mk-blur-0: 0px;
  --mk-blur-1: 6px;
  --mk-blur-2: 10px;
  --mk-blur: var(--mk-blur-1);

  /* --- 影(浮遊感) --- */
  --mk-shadow-lift: 0 12px 30px rgba(0, 0, 0, 0.14);
  --mk-shadow-soft: 0 10px 22px rgba(0, 0, 0, 0.10);

  /* --- アンダーライン --- */
  --mk-underline-thickness: 2px;
  --mk-underline-offset: 0.2em;

  /* --- フォーカスリング --- */
  --mk-ring-color: rgba(59, 130, 246, 0.35);
  --mk-ring-size: 3px;

  /* --- スケルトン(ロード中) --- */
  --mk-skeleton-a: rgba(0, 0, 0, 0.06);
  --mk-skeleton-b: rgba(0, 0, 0, 0.12);
  --mk-skeleton-speed: 1250ms;

  /* --- スピナー --- */
  --mk-spinner-size: 1.2em;
  --mk-spinner-border: 2px;

  /* --- テキスト分割表示 --- */
  --mk-char-step: 25ms; /* 1文字ごとの遅延 */


  /* --- ダイアログ / ドロワー --- */
  --mk-dialog-duration: var(--mk-duration-slow);
  --mk-dialog-ease: var(--mk-ease-out);
  --mk-dialog-backdrop-opacity: 0.45;
  --mk-dialog-offset: var(--mk-distance-3);
  --mk-dialog-scale-from: 0.995;
  --mk-dialog-radius: 18px;

  /* --- 折りたたみ(アコーディオン) --- */
  --mk-collapse-duration: var(--mk-duration-medium);
  --mk-collapse-ease: var(--mk-ease);

  /* --- Toast / 通知 --- */
  --mk-toast-gap: 10px;
  --mk-toast-width: min(360px, calc(100vw - 24px));
  --mk-toast-duration: var(--mk-duration-medium);
  --mk-toast-ease: var(--mk-ease-out);

  /* --- スクロール進捗(トップバー) --- */
  --mk-scroll-progress-top: 0px;
  --mk-scroll-progress-height: 3px;
  --mk-scroll-progress-ease: linear;

  /* --- ナビ下線インジケータ --- */
  --mk-nav-underline-height: 2px;
  --mk-nav-underline-radius: 999px;
  --mk-nav-duration: var(--mk-duration-medium);
  --mk-nav-ease: var(--mk-ease-out);


  /* --- UI state(idle/loading/success/error) --- */
  --mk-state-hold-success: 900ms;
  --mk-state-hold-error: 1400ms;


  /* --- Tabs --- */
  --mk-tabs-gap: 10px;
  --mk-tabs-radius: 999px;
  --mk-tabs-duration: var(--mk-duration-medium);
  --mk-tabs-ease: var(--mk-ease-out);
  --mk-tabs-underline-height: 2px;
  --mk-tabs-underline-color: currentColor;

  /* --- Tooltip --- */
  --mk-tooltip-bg: rgba(0, 0, 0, 0.82);
  --mk-tooltip-fg: #fff;
  --mk-tooltip-radius: 10px;
  --mk-tooltip-padding: 10px 12px;
  --mk-tooltip-gap: 8px;
  --mk-tooltip-shadow: 0 18px 60px rgba(0,0,0,0.35);
  --mk-tooltip-max-width: 260px;

  /* --- Stepper --- */
  --mk-stepper-gap: 14px;
  --mk-step-size: 28px;
  --mk-step-line: rgba(255, 255, 255, 0.14);
  --mk-step-done: rgba(59, 130, 246, 1);
  --mk-step-current: rgba(255, 255, 255, 0.92);
  --mk-step-todo: rgba(255, 255, 255, 0.5);

}

/* motion減衰設定のあるユーザーには、動きを最小化して“結果”を出します */
@media (prefers-reduced-motion: reduce) {
  :root {
    --mk-duration-fast: 0ms;
    --mk-duration-medium: 0ms;
    --mk-duration-slow: 0ms;
    --mk-duration-slower: 0ms;
    --mk-duration: 0ms;
    --mk-delay: 0ms;
  }
}
tailwind-motion-kit/css/06_text.css
css
tailwind-motion-kit/css/06_text.css
/*!
 * Tailwind Motion Kit (mk) - Text
 * ------------------------------
 * JSでテキストを span に分割し、1文字/単語ずつ出すためのスタイルです。
 *
 * 例:
 *   <h2 class="mk-text-reveal mk-reveal mk-reveal-fade" data-mk-inview data-mk-text="chars">
 *     Hello world
 *   </h2>
 *
 * JS:
 *   import { initTextReveal } from "./js/text-reveal.js";
 *   initTextReveal();
 */

.mk-text-reveal {
  /* スペースや改行を壊しにくくします */
  white-space: pre-wrap;
}

.mk-text-reveal .mk-char,
.mk-text-reveal .mk-word {
  display: inline-block;
  will-change: transform, opacity, filter;
}

/* 初期状態 */
.mk-text-reveal[data-mk-inview]:not([data-mk-inview="true"]) .mk-char,
.mk-text-reveal[data-mk-inview]:not([data-mk-inview="true"]) .mk-word {
  opacity: 0;
  transform: translate3d(0, var(--mk-text-y, 0.35em), 0);
  filter: blur(var(--mk-text-blur, 4px));

  transition-property: transform, opacity, filter;
  transition-duration: var(--mk-text-duration, var(--mk-duration-slow));
  transition-timing-function: var(--mk-text-ease, var(--mk-ease));
  transition-delay: calc(var(--mk-delay) + var(--mk-char-delay, 0ms));
}

/* 表示状態 */
.mk-text-reveal[data-mk-inview="true"] .mk-char,
.mk-text-reveal[data-mk-inview="true"] .mk-word {
  opacity: 1;
  transform: translate3d(0, 0, 0);
  filter: blur(0);
}

/* “ハイライトが走る”演出(見出し向き) */
.mk-text-highlight {
  --mk-highlight-color: rgba(255, 231, 160, 0.55);
  background-image: linear-gradient(var(--mk-highlight-color), var(--mk-highlight-color));
  background-repeat: no-repeat;
  background-position: 0 90%;
  background-size: 0% 0.6em;
}

.mk-text-highlight[data-mk-inview] {
  transition: background-size var(--mk-duration-slow) var(--mk-ease) var(--mk-delay);
}

.mk-text-highlight[data-mk-inview="true"] {
  background-size: 100% 0.6em;
}

@media (prefers-reduced-motion: reduce) {
  .mk-text-reveal .mk-char,
  .mk-text-reveal .mk-word,
  .mk-text-highlight[data-mk-inview] {
    transition: none !important;
  }
  .mk-text-reveal[data-mk-inview]:not([data-mk-inview="true"]) .mk-char,
  .mk-text-reveal[data-mk-inview]:not([data-mk-inview="true"]) .mk-word {
    opacity: 1 !important;
    transform: none !important;
    filter: none !important;
  }
  .mk-text-highlight[data-mk-inview] {
    background-size: 100% 0.6em !important;
  }
}
tailwind-motion-kit/js/text-split.js
js
tailwind-motion-kit/js/text-split.js
// Tailwind Motion Kit (mk) - Text split
// ------------------------------------
// テキストを span に分割して、文字/単語ごとの遅延を付けます。
// アクセシビリティのため、元テキストを aria-label に残し、span は aria-hidden にします。

function parseTimeToMs(raw, fallbackMs) {
  if (raw == null) return fallbackMs;
  const v = String(raw).trim();
  if (!v) return fallbackMs;
  if (v.endsWith("ms")) return Number(v.replace("ms", "")) || fallbackMs;
  if (v.endsWith("s")) return (Number(v.replace("s", "")) * 1000) || fallbackMs;
  const n = Number(v);
  return Number.isFinite(n) ? n : fallbackMs;
}

function getStepMs(el, fallbackMs) {
  const attr = el.getAttribute("data-mk-step");
  if (attr) return parseTimeToMs(attr, fallbackMs);

  // CSS変数 --mk-char-step を読む(例: 25ms)
  const cs = getComputedStyle(el).getPropertyValue("--mk-char-step");
  return parseTimeToMs(cs, fallbackMs);
}

function clearChildren(el) {
  while (el.firstChild) el.removeChild(el.firstChild);
}

export function splitText(el, {
  by = "chars", // "chars" | "words"
  stepMs = null,
  preserveAria = true,
} = {}) {
  if (!el || !(el instanceof Element)) return;
  if (el.getAttribute("data-mk-split-ready") === "true") return;

  const original = el.textContent ?? "";
  const step = stepMs == null ? getStepMs(el, 25) : stepMs;

  if (preserveAria) {
    if (!el.hasAttribute("aria-label")) el.setAttribute("aria-label", original);
  }

  el.setAttribute("data-mk-split-ready", "true");
  clearChildren(el);

  if (by === "words") {
    const words = original.split(/(\s+)/); // 空白も保持
    let index = 0;

    words.forEach((token) => {
      if (/^\s+$/.test(token)) {
        el.appendChild(document.createTextNode(token));
        return;
      }
      const span = document.createElement("span");
      span.className = "mk-word";
      span.setAttribute("aria-hidden", "true");
      span.style.setProperty("--mk-char-delay", `${index * step}ms`);
      span.textContent = token;
      el.appendChild(span);
      index += 1;
    });
    return;
  }

  // chars
  let index = 0;
  for (const ch of original) {
    const span = document.createElement("span");
    span.className = "mk-char";
    span.setAttribute("aria-hidden", "true");
    span.style.setProperty("--mk-char-delay", `${index * step}ms`);
    span.textContent = ch === " " ? "\u00A0" : ch;
    el.appendChild(span);
    index += 1;
  }
}
tailwind-motion-kit/js/text-reveal.js
js
tailwind-motion-kit/js/text-reveal.js
// Tailwind Motion Kit (mk) - Text reveal init
// ------------------------------------------
// data-mk-text="chars|words" を持つ要素を自動分割します。
// inview のトリガーも必要なので、data-mk-inview が無い場合は付与します。

import { splitText } from "./text-split.js";

export function initTextReveal({
  selector = "[data-mk-text]",
  preserveAria = true,
} = {}) {
  document.querySelectorAll(selector).forEach((el) => {
    const mode = (el.getAttribute("data-mk-text") || "chars").toLowerCase();
    if (!el.hasAttribute("data-mk-inview")) el.setAttribute("data-mk-inview", "");
    splitText(el, { by: mode === "words" ? "words" : "chars", preserveAria });
  });
}
tailwind-motion-kit/js/inview.js
js
tailwind-motion-kit/js/inview.js
// Tailwind Motion Kit (mk) - InView observer
// -----------------------------------------
// 目的: data-mk-inview を持つ要素に対して、ビューポート侵入時に
//      data-mk-inview="true" を付与し、CSS側の reveal 等を発火させます。

function prefersReducedMotion() {
  return window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}

function toMs(value, fallbackMs) {
  if (value == null || value === "") return fallbackMs;
  const v = String(value).trim();
  if (v.endsWith("ms")) return Number(v.replace("ms", "")) || fallbackMs;
  if (v.endsWith("s")) return (Number(v.replace("s", "")) * 1000) || fallbackMs;
  const n = Number(v);
  return Number.isFinite(n) ? n : fallbackMs;
}

function prepareElement(el) {
  // data-mk-* を “CSS変数” へ反映(style="" を汚したくない時に便利)
  // 例: <div data-mk-delay="120" data-mk-duration="700" data-mk-distance="16" data-mk-blur="0">
  const delay = el.getAttribute("data-mk-delay");
  if (delay != null && delay !== "") el.style.setProperty("--mk-delay", `${toMs(delay, 0)}ms`);

  const duration = el.getAttribute("data-mk-duration");
  if (duration != null && duration !== "") el.style.setProperty("--mk-duration", `${toMs(duration, 0)}ms`);

  const distance = el.getAttribute("data-mk-distance");
  if (distance != null && distance !== "") el.style.setProperty("--mk-distance", `${Number(distance)}px`);

  const blur = el.getAttribute("data-mk-blur");
  if (blur != null && blur !== "") el.style.setProperty("--mk-blur", `${Number(blur)}px`);
}

export function initInView({
  selector = "[data-mk-inview]",
  threshold = 0.15,
  rootMargin = "0px 0px -12% 0px",
  once = true,
  onEnter,
  onExit,
} = {}) {
  const reduce = prefersReducedMotion();
  const elements = new Set();

  const scan = () => {
    document.querySelectorAll(selector).forEach((el) => elements.add(el));
  };

  scan();

  // reduce motion: 最初から “true” へ
  if (reduce) {
    elements.forEach((el) => {
      prepareElement(el);
      el.setAttribute("data-mk-inview", "true");
    });
    return {
      refresh() {
        scan();
        elements.forEach((el) => el.setAttribute("data-mk-inview", "true"));
      },
      destroy() {},
      observer: null,
      elements,
    };
  }

  const io = new IntersectionObserver((entries) => {
    entries.forEach((entry) => {
      const el = entry.target;

      // 要素ごとの上書き: data-mk-inview-once="false"
      const elOnceAttr = el.getAttribute("data-mk-inview-once");
      const elOnce = elOnceAttr == null ? once : elOnceAttr !== "false";

      if (entry.isIntersecting) {
        prepareElement(el);
        el.setAttribute("data-mk-inview", "true");
        if (typeof onEnter === "function") onEnter(el, entry);

        if (elOnce) io.unobserve(el);
      } else if (!elOnce) {
        el.setAttribute("data-mk-inview", "false");
        if (typeof onExit === "function") onExit(el, entry);
      }
    });
  }, { threshold, rootMargin });

  elements.forEach((el) => io.observe(el));

  return {
    refresh() {
      scan();
      elements.forEach((el) => io.observe(el));
    },
    destroy() {
      io.disconnect();
      elements.clear();
    },
    observer: io,
    elements,
  };
}
Preview

Tailwind Motion Kit — Text Reveal

実運用では initTextReveal() と splitText() を使って自動分割します。

Tips
  • • デザイン側は CSS 変数(tokens)で一括調整できます。
  • • 動きは “短く・小さく・同じ癖” を優先すると品位が保てます。
  • • prefers-reduced-motion を必ず考慮してください。