text-reveal.tsx 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143
  1. import { createEffect, on, onCleanup, onMount } from "solid-js"
  2. import { createStore } from "solid-js/store"
  3. const px = (value: number | string | undefined, fallback: number) => {
  4. if (typeof value === "number") return `${value}px`
  5. if (typeof value === "string") return value
  6. return `${fallback}px`
  7. }
  8. const ms = (value: number | string | undefined, fallback: number) => {
  9. if (typeof value === "number") return `${value}ms`
  10. if (typeof value === "string") return value
  11. return `${fallback}ms`
  12. }
  13. const pct = (value: number | undefined, fallback: number) => {
  14. const v = value ?? fallback
  15. return `${v}%`
  16. }
  17. export function TextReveal(props: {
  18. text?: string
  19. class?: string
  20. duration?: number | string
  21. /** Gradient edge softness as a percentage of the mask (0 = hard wipe, 17 = soft). */
  22. edge?: number
  23. /** Optional small vertical travel for entering text (px). Default 0. */
  24. travel?: number | string
  25. spring?: string
  26. springSoft?: string
  27. growOnly?: boolean
  28. truncate?: boolean
  29. }) {
  30. const [state, setState] = createStore({
  31. cur: props.text,
  32. old: undefined as string | undefined,
  33. width: "auto",
  34. ready: false,
  35. swapping: false,
  36. })
  37. const cur = () => state.cur
  38. const old = () => state.old
  39. const width = () => state.width
  40. const ready = () => state.ready
  41. const swapping = () => state.swapping
  42. let inRef: HTMLSpanElement | undefined
  43. let outRef: HTMLSpanElement | undefined
  44. let rootRef: HTMLSpanElement | undefined
  45. let frame: number | undefined
  46. const win = () => inRef?.scrollWidth ?? 0
  47. const wout = () => outRef?.scrollWidth ?? 0
  48. const widen = (next: number) => {
  49. if (next <= 0) return
  50. if (props.growOnly ?? true) {
  51. const prev = Number.parseFloat(width())
  52. if (Number.isFinite(prev) && next <= prev) return
  53. }
  54. setState("width", `${next}px`)
  55. }
  56. createEffect(
  57. on(
  58. () => props.text,
  59. (next, prev) => {
  60. if (next === prev) return
  61. if (typeof next === "string" && typeof prev === "string" && next.startsWith(prev)) {
  62. setState("cur", next)
  63. widen(win())
  64. return
  65. }
  66. setState("swapping", true)
  67. setState("old", prev)
  68. setState("cur", next)
  69. if (typeof requestAnimationFrame !== "function") {
  70. widen(Math.max(win(), wout()))
  71. rootRef?.offsetHeight
  72. setState("swapping", false)
  73. return
  74. }
  75. if (frame !== undefined && typeof cancelAnimationFrame === "function") cancelAnimationFrame(frame)
  76. frame = requestAnimationFrame(() => {
  77. widen(Math.max(win(), wout()))
  78. rootRef?.offsetHeight
  79. setState("swapping", false)
  80. frame = undefined
  81. })
  82. },
  83. ),
  84. )
  85. onMount(() => {
  86. widen(win())
  87. const fonts = typeof document !== "undefined" ? document.fonts : undefined
  88. if (typeof requestAnimationFrame !== "function") {
  89. setState("ready", true)
  90. return
  91. }
  92. if (!fonts) {
  93. requestAnimationFrame(() => setState("ready", true))
  94. return
  95. }
  96. fonts.ready.finally(() => {
  97. widen(win())
  98. requestAnimationFrame(() => setState("ready", true))
  99. })
  100. })
  101. onCleanup(() => {
  102. if (frame === undefined || typeof cancelAnimationFrame !== "function") return
  103. cancelAnimationFrame(frame)
  104. })
  105. return (
  106. <span
  107. ref={rootRef}
  108. data-component="text-reveal"
  109. data-ready={ready() ? "true" : "false"}
  110. data-swapping={swapping() ? "true" : "false"}
  111. data-truncate={props.truncate ? "true" : "false"}
  112. class={props.class}
  113. aria-label={props.text ?? ""}
  114. style={{
  115. "--text-reveal-duration": ms(props.duration, 450),
  116. "--text-reveal-edge": pct(props.edge, 17),
  117. "--text-reveal-travel": px(props.travel, 0),
  118. "--text-reveal-spring": props.spring ?? "cubic-bezier(0.34, 1.08, 0.64, 1)",
  119. "--text-reveal-spring-soft": props.springSoft ?? "cubic-bezier(0.34, 1, 0.64, 1)",
  120. }}
  121. >
  122. <span data-slot="text-reveal-track" style={{ width: props.truncate ? "100%" : width() }}>
  123. <span data-slot="text-reveal-entering" ref={inRef}>
  124. {cur() ?? "\u00A0"}
  125. </span>
  126. <span data-slot="text-reveal-leaving" ref={outRef}>
  127. {old() ?? "\u00A0"}
  128. </span>
  129. </span>
  130. </span>
  131. )
  132. }