@edwinvakayil/calligraphy 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -17,6 +17,11 @@ interface TypographyProviderProps {
17
17
  children: React.ReactNode;
18
18
  }
19
19
  declare const TypographyProvider: React.FC<TypographyProviderProps>;
20
+ /**
21
+ * Returns the resolved theme from the nearest TypographyProvider.
22
+ * Falls back to DEFAULT_THEME if used outside a provider.
23
+ */
24
+ declare function useTypographyTheme(): Required<TypographyTheme>;
20
25
 
21
26
  type TypographyVariant = "Display" | "H1" | "H2" | "H3" | "H4" | "H5" | "H6" | "Subheading" | "Overline" | "Body" | "Label" | "Caption";
22
27
  type TextAlign = "left" | "center" | "right" | "justify";
@@ -76,8 +81,83 @@ type TextAlign = "left" | "center" | "right" | "justify";
76
81
  * wordFade per-word cross-dissolve with warmth scale, no Y movement
77
82
  * rotateIn full Y-axis card-flip per word (face-up reveal)
78
83
  * pressIn presses to 0.92 then springs past 1 (physical button feel)
84
+ * maskSweep mask-image sweep reveals text left to right
85
+ * gradSweep gradient sweep across text then resolves to solid
79
86
  */
80
- type HeroAnimation = "rise" | "stagger" | "clip" | "pop" | "letters" | "blur" | "flip" | "swipe" | "typewriter" | "bounce" | "velvet" | "curtain" | "morph" | "ground" | "cascade" | "spotlight" | "ink" | "hinge" | "stretch" | "peel" | "fold" | "shear" | "ripple" | "cinch" | "tiltrise" | "cardFlip" | "converge" | "splitRise" | "tectonic" | "stratify" | "unfurl" | "gravityWell" | "orbit" | "liquid" | "noiseFade" | "slab" | "thread" | "billboard" | "glassReveal" | "wordPop" | "charDrop" | "scanline" | "chromaShift" | "wordFade" | "rotateIn" | "pressIn";
87
+ type HeroAnimation = "rise" | "stagger" | "clip" | "pop" | "letters" | "blur" | "flip" | "swipe" | "typewriter" | "bounce" | "velvet" | "curtain" | "morph" | "ground" | "cascade" | "spotlight" | "ink" | "hinge" | "stretch" | "peel" | "fold" | "shear" | "ripple" | "cinch" | "tiltrise" | "cardFlip" | "converge" | "splitRise" | "tectonic" | "stratify" | "unfurl" | "gravityWell" | "orbit" | "liquid" | "noiseFade" | "slab" | "thread" | "billboard" | "glassReveal" | "wordPop" | "charDrop" | "scanline" | "chromaShift" | "wordFade" | "rotateIn" | "pressIn" | "maskSweep" | "gradSweep";
88
+ /**
89
+ * Custom motion configuration for a Typography element.
90
+ * Use this when the built-in `animation` presets don't fit your needs —
91
+ * for example when you have brand-specific easing tokens, or when you want
92
+ * to animate a non-hero variant like H2 or Body.
93
+ *
94
+ * @example
95
+ * // Whole-element fade up
96
+ * motionConfig={{
97
+ * keyframes: `from { opacity: 0; transform: translateY(24px); }
98
+ * to { opacity: 1; transform: none; }`,
99
+ * duration: "0.8s",
100
+ * easing: "cubic-bezier(0.16, 1, 0.3, 1)",
101
+ * delay: "0.2s",
102
+ * split: "none",
103
+ * }}
104
+ *
105
+ * @example
106
+ * // Per-word stagger with custom keyframe
107
+ * motionConfig={{
108
+ * keyframes: `from { opacity: 0; transform: skewX(8deg) translateX(-12px); }
109
+ * to { opacity: 1; transform: none; }`,
110
+ * duration: "0.6s",
111
+ * easing: "cubic-bezier(0.16, 1, 0.3, 1)",
112
+ * staggerDelay: 0.08,
113
+ * split: "words",
114
+ * }}
115
+ */
116
+ interface MotionConfig {
117
+ /**
118
+ * CSS keyframes body — the content between @keyframes name { … }.
119
+ * Do not include the @keyframes rule or a name; the component generates both.
120
+ *
121
+ * @example "from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: none; }"
122
+ */
123
+ keyframes: string;
124
+ /**
125
+ * Animation duration. Accepts any CSS time value.
126
+ * @default "0.8s"
127
+ */
128
+ duration?: string;
129
+ /**
130
+ * CSS easing function.
131
+ * @default "cubic-bezier(0.16, 1, 0.3, 1)"
132
+ */
133
+ easing?: string;
134
+ /**
135
+ * Initial delay before the animation begins.
136
+ * @default "0s"
137
+ */
138
+ delay?: string;
139
+ /**
140
+ * animation-fill-mode value.
141
+ * @default "both"
142
+ */
143
+ fillMode?: "none" | "forwards" | "backwards" | "both";
144
+ /**
145
+ * How to split the text before animating.
146
+ *
147
+ * - "none" — animate the whole element (default)
148
+ * - "words" — wrap each word in a <span> and stagger them
149
+ * - "chars" — wrap each character in a <span> and stagger them
150
+ *
151
+ * @default "none"
152
+ */
153
+ split?: "none" | "words" | "chars";
154
+ /**
155
+ * Delay increment between each word or character span (in seconds).
156
+ * Only used when split is "words" or "chars".
157
+ * @default 0.07 (words) | 0.04 (chars)
158
+ */
159
+ staggerDelay?: number;
160
+ }
81
161
  interface TypographyProps extends HTMLAttributes<HTMLElement> {
82
162
  /** Typography scale variant */
83
163
  variant?: TypographyVariant;
@@ -94,10 +174,31 @@ interface TypographyProps extends HTMLAttributes<HTMLElement> {
94
174
  /** Clamp to N lines */
95
175
  maxLines?: number;
96
176
  /**
97
- * Hero entrance animation.
177
+ * Built-in hero entrance animation.
98
178
  * Only applied on variant="Display" or variant="H1".
99
179
  */
100
180
  animation?: HeroAnimation;
181
+ /**
182
+ * Custom motion config — use your own keyframes, easing, and split strategy.
183
+ * Works on ALL variants (not just heroes).
184
+ * Takes precedence over `animation` when both are provided.
185
+ *
186
+ * @see MotionConfig
187
+ */
188
+ motionConfig?: MotionConfig;
189
+ /**
190
+ * Ref callback giving direct access to the rendered DOM element.
191
+ * Use this to drive animations with GSAP, Framer Motion, or the Web
192
+ * Animations API. Called after mount and on every re-render.
193
+ * Takes precedence over both `animation` and `motionConfig`.
194
+ *
195
+ * @example
196
+ * motionRef={(el) => {
197
+ * if (!el) return;
198
+ * gsap.from(el, { opacity: 0, y: 40, duration: 0.9 });
199
+ * }}
200
+ */
201
+ motionRef?: (el: HTMLElement | null) => void;
101
202
  /**
102
203
  * Italic accent for Display / H1 heroes.
103
204
  * When true, <em> children render in Instrument Serif italic + accentColor.
@@ -138,4 +239,4 @@ declare function injectFont(url: string): void;
138
239
  */
139
240
  declare function preloadFonts(families: string[]): void;
140
241
 
141
- export { GOOGLE_FONTS, HeroAnimation, TextAlign, Typography, TypographyProps, TypographyProvider, TypographyProviderProps, TypographyTheme, TypographyVariant, buildFontUrl, Typography as default, injectFont, preloadFonts };
242
+ export { GOOGLE_FONTS, HeroAnimation, MotionConfig, TextAlign, Typography, TypographyProps, TypographyProvider, TypographyProviderProps, TypographyTheme, TypographyVariant, buildFontUrl, Typography as default, injectFont, preloadFonts, useTypographyTheme };
package/dist/index.esm.js CHANGED
@@ -526,6 +526,17 @@ function wrapSplitRise(html) {
526
526
  function wrapThread(html) {
527
527
  return wrapChars(html, "rts-thread-ch", 0.04);
528
528
  }
529
+ /**
530
+ * Called by Typography after dangerouslySetInnerHTML for "thread" animation.
531
+ * Stamps the sine-wave Y offset as --ty on each character span.
532
+ */
533
+ function applyThreadOffsets(container) {
534
+ const spans = container.querySelectorAll(".rts-thread-ch");
535
+ spans.forEach((el, i) => {
536
+ const offset = Math.sin(i * 0.85) * 22;
537
+ el.style.setProperty("--ty", `${offset.toFixed(1)}px`);
538
+ });
539
+ }
529
540
  // ─── Public API ───────────────────────────────────────────────────────────────
530
541
  function buildSplitHTML(animation, html) {
531
542
  switch (animation) {
@@ -566,6 +577,88 @@ function buildSplitHTML(animation, html) {
566
577
  default: return html;
567
578
  }
568
579
  }
580
+ // ─── Custom motion builder ────────────────────────────────────────────────────
581
+ /**
582
+ * Injects a one-off @keyframes rule with a generated name and returns that name.
583
+ * Deduped by a hash of the keyframe body so the same keyframe is only injected once.
584
+ */
585
+ function injectCustomKeyframes(keyframeBody) {
586
+ const hash = keyframeBody
587
+ .split("")
588
+ .reduce((acc, ch) => (Math.imul(31, acc) + ch.charCodeAt(0)) | 0, 0)
589
+ .toString(36)
590
+ .replace("-", "n");
591
+ const name = `rts-custom-${hash}`;
592
+ if (typeof document === "undefined")
593
+ return name;
594
+ if (document.getElementById(name))
595
+ return name;
596
+ const style = document.createElement("style");
597
+ style.id = name;
598
+ style.textContent = `@keyframes ${name} { ${keyframeBody} }`;
599
+ document.head.appendChild(style);
600
+ return name;
601
+ }
602
+ /**
603
+ * Builds the innerHTML for a custom motionConfig.
604
+ * - split "none" → returns the raw html unchanged; caller applies class to element
605
+ * - split "words" → wraps each word in a span with the animation + stagger delay
606
+ * - split "chars" → wraps each character in a span with the animation + stagger delay
607
+ *
608
+ * Returns { html, animationValue } where animationValue is the full CSS animation
609
+ * shorthand to set on each span (or on the element itself when split is "none").
610
+ */
611
+ function buildCustomHTML(html, keyframeName, duration, easing, delay, fillMode, split, staggerDelay) {
612
+ var _a;
613
+ const baseAnimation = `${keyframeName} ${duration} ${easing} ${delay} ${fillMode}`;
614
+ if (split === "none") {
615
+ return { html, baseAnimation };
616
+ }
617
+ if (split === "words") {
618
+ const tokens = (_a = html.match(/(<em>[\s\S]*?<\/em>|[^\s]+)/g)) !== null && _a !== void 0 ? _a : [];
619
+ const result = tokens.map((tok, i) => {
620
+ const totalDelay = i === 0
621
+ ? delay
622
+ : `${(parseFloat(delay) + i * staggerDelay).toFixed(3)}s`;
623
+ const anim = `${keyframeName} ${duration} ${easing} ${totalDelay} ${fillMode}`;
624
+ if (tok.startsWith("<em>")) {
625
+ return `<em><span style="display:inline-block;animation:${anim}">${tok.slice(4, -5)}</span></em>`;
626
+ }
627
+ return `<span style="display:inline-block;animation:${anim}">${tok}</span>`;
628
+ });
629
+ return { html: result.join(" "), baseAnimation };
630
+ }
631
+ // chars
632
+ const result = [];
633
+ let inEm = false, charIndex = 0, i = 0;
634
+ while (i < html.length) {
635
+ if (html.startsWith("<em>", i)) {
636
+ inEm = true;
637
+ i += 4;
638
+ continue;
639
+ }
640
+ if (html.startsWith("</em>", i)) {
641
+ inEm = false;
642
+ i += 5;
643
+ continue;
644
+ }
645
+ const ch = html[i];
646
+ if (ch === " ") {
647
+ result.push(" ");
648
+ i++;
649
+ continue;
650
+ }
651
+ const totalDelay = charIndex === 0
652
+ ? delay
653
+ : `${(parseFloat(delay) + charIndex * staggerDelay).toFixed(3)}s`;
654
+ const anim = `${keyframeName} ${duration} ${easing} ${totalDelay} ${fillMode}`;
655
+ const span = `<span style="display:inline-block;animation:${anim}">${ch}</span>`;
656
+ result.push(inEm ? `<em>${span}</em>` : span);
657
+ charIndex++;
658
+ i++;
659
+ }
660
+ return { html: result.join(""), baseAnimation };
661
+ }
569
662
 
570
663
  // ─── Defaults ─────────────────────────────────────────────────────────────────
571
664
  const DEFAULT_THEME = {
@@ -603,7 +696,7 @@ function useTypographyTheme() {
603
696
  return useContext(TypographyContext);
604
697
  }
605
698
 
606
- // ─── Static maps ─────────────────────────────────────────────────────────────
699
+ // ─── Variant HTML tag map ───────────────────────────────────────────────────
607
700
  const variantTagMap = {
608
701
  Display: "h1",
609
702
  H1: "h1",
@@ -618,6 +711,7 @@ const variantTagMap = {
618
711
  Label: "label",
619
712
  Caption: "span",
620
713
  };
714
+ // ─── Variant → base CSS styles ────────────────────────────────────────────────
621
715
  const variantStyleMap = {
622
716
  Display: {
623
717
  fontSize: "clamp(2.5rem, 6vw, 5rem)",
@@ -693,9 +787,15 @@ const variantStyleMap = {
693
787
  letterSpacing: "0.03em",
694
788
  },
695
789
  };
696
- // ─── Constants ───────────────────────────────────────────────────────────────
790
+ // ─── Constants ────────────────────────────────────────────────────────────────
791
+ // Always pre-loaded for hero variants so toggling italic is instant with no FOUC
697
792
  const INSTRUMENT_SERIF_URL = "https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&display=swap";
698
- // ─── Helpers ─────────────────────────────────────────────────────────────────
793
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
794
+ /**
795
+ * Serialise React children to a raw HTML string.
796
+ * Handles plain strings and <em>text</em> elements.
797
+ * Used to feed text into the animation split-builders.
798
+ */
699
799
  function childrenToHTML(children) {
700
800
  var _a, _b;
701
801
  return ((_b = (_a = Children.map(children, (child) => {
@@ -703,12 +803,19 @@ function childrenToHTML(children) {
703
803
  return String(child);
704
804
  }
705
805
  if (isValidElement(child) && child.type === "em") {
706
- const inner = typeof child.props.children === "string" ? child.props.children : "";
806
+ const inner = typeof child.props.children === "string"
807
+ ? child.props.children
808
+ : "";
707
809
  return `<em>${inner}</em>`;
708
810
  }
709
811
  return "";
710
812
  })) === null || _a === void 0 ? void 0 : _a.join("")) !== null && _b !== void 0 ? _b : "");
711
813
  }
814
+ /**
815
+ * Standard (no-animation) render path.
816
+ * Clones <em> children with explicit inline styles so the font switch is
817
+ * guaranteed — parent fontFamily cannot override a child's own inline style.
818
+ */
712
819
  function renderChildrenWithEmStyles(children, italic, accentColor, headingFont) {
713
820
  const italicStyle = {
714
821
  fontFamily: "'Instrument Serif', serif",
@@ -729,6 +836,11 @@ function renderChildrenWithEmStyles(children, italic, accentColor, headingFont)
729
836
  return child;
730
837
  });
731
838
  }
839
+ /**
840
+ * Animation (dangerouslySetInnerHTML) render path.
841
+ * After the DOM is written we walk it and stamp inline styles onto every
842
+ * <em> and <em > span — guaranteed to beat any inherited fontFamily.
843
+ */
732
844
  function applyEmStylesDOM(container, italic, accentColor, headingFont) {
733
845
  const apply = (el) => {
734
846
  if (italic) {
@@ -738,7 +850,9 @@ function applyEmStylesDOM(container, italic, accentColor, headingFont) {
738
850
  el.style.color = accentColor;
739
851
  }
740
852
  else {
741
- el.style.fontFamily = headingFont ? `'${headingFont}', sans-serif` : "inherit";
853
+ el.style.fontFamily = headingFont
854
+ ? `'${headingFont}', sans-serif`
855
+ : "inherit";
742
856
  el.style.fontStyle = "normal";
743
857
  el.style.fontWeight = "inherit";
744
858
  el.style.color = "inherit";
@@ -747,61 +861,98 @@ function applyEmStylesDOM(container, italic, accentColor, headingFont) {
747
861
  container.querySelectorAll("em").forEach(apply);
748
862
  container.querySelectorAll("em > span").forEach(apply);
749
863
  }
750
- // ─── Component ───────────────────────────────────────────────────────────────
864
+ // ─── Component ────────────────────────────────────────────────────────────────
751
865
  const Typography = (_a) => {
752
- var _b;
753
- var { variant = "Body", font: fontProp, color: colorProp, animation: animationProp, italic: italicProp, accentColor: accentColorProp, align, className, style, children, as, truncate, maxLines } = _a, rest = __rest(_a, ["variant", "font", "color", "animation", "italic", "accentColor", "align", "className", "style", "children", "as", "truncate", "maxLines"]);
866
+ var _b, _c, _d, _e, _f, _g, _h, _j, _k;
867
+ var { variant = "Body", font: fontProp, color: colorProp, animation: animationProp, motionConfig, motionRef, italic: italicProp, accentColor: accentColorProp, align, className, style, children, as, truncate, maxLines } = _a, rest = __rest(_a, ["variant", "font", "color", "animation", "motionConfig", "motionRef", "italic", "accentColor", "align", "className", "style", "children", "as", "truncate", "maxLines"]);
754
868
  const theme = useTypographyTheme();
755
869
  const isHero = variant === "Display" || variant === "H1";
756
870
  const ref = useRef(null);
757
- // Prop wins; fall back to theme; fall back to built-in default
871
+ // Prop wins theme built-in default
758
872
  const font = fontProp !== null && fontProp !== void 0 ? fontProp : (theme.font || undefined);
759
873
  const color = colorProp !== null && colorProp !== void 0 ? colorProp : (theme.color || undefined);
760
- const animation = isHero ? ((_b = animationProp !== null && animationProp !== void 0 ? animationProp : theme.animation) !== null && _b !== void 0 ? _b : undefined) : undefined;
761
- const italic = italicProp !== null && italicProp !== void 0 ? italicProp : theme.italic;
762
- const accentColor = accentColorProp !== null && accentColorProp !== void 0 ? accentColorProp : theme.accentColor;
763
- // ── useInsertionEffect: inject <link> and <style> tags ────────────────────
764
- //
765
- // WHY useInsertionEffect instead of plain render-phase calls:
766
- //
767
- // 1. Server safety — useInsertionEffect (like all effects) is never called
768
- // on the server, so document.createElement / document.head never run
769
- // during SSR. The isBrowser guard in ssr.ts is a belt-and-suspenders
770
- // backup, but the effect boundary is the real guarantee.
771
- //
772
- // 2. Correctness — React 18 concurrent mode can call the render function
773
- // multiple times before committing. Doing DOM work in render can fire
774
- // those side-effects redundantly or out of order. useInsertionEffect
775
- // fires synchronously before the browser paints, once per commit.
776
- //
777
- // 3. No FOUC — because it fires before paint (earlier than useLayoutEffect),
778
- // the <style> tag is in the DOM before any text is visible, so there is
779
- // no flash of unstyled / wrong-font text.
874
+ const animation = isHero
875
+ ? ((_b = animationProp !== null && animationProp !== void 0 ? animationProp : theme.animation) !== null && _b !== void 0 ? _b : undefined)
876
+ : undefined;
877
+ const italic = (_c = italicProp !== null && italicProp !== void 0 ? italicProp : theme.italic) !== null && _c !== void 0 ? _c : false;
878
+ const accentColor = (_d = accentColorProp !== null && accentColorProp !== void 0 ? accentColorProp : theme.accentColor) !== null && _d !== void 0 ? _d : "#c8b89a";
879
+ // ── Font & style injection ─────────────────────────────────────────────────
780
880
  useInsertionEffect(() => {
781
- // Instrument Serif — always pre-load for hero so toggling italic is instant
782
881
  if (isHero) {
783
882
  injectFont(INSTRUMENT_SERIF_URL);
784
883
  }
785
- // Heading Google Font
786
884
  if (font && GOOGLE_FONTS.includes(font)) {
787
885
  injectFont(buildFontUrl(font));
788
886
  }
789
- // Animation keyframe stylesheet
790
887
  if (animation && isHero) {
791
888
  injectAnimationStyles();
792
889
  }
793
- }, [isHero, font, animation]);
794
- // ── useEffect: re-stamp inline styles on <em> after DOM updates ───────────
890
+ // Inject custom keyframes as soon as the prop arrives
891
+ if (motionConfig === null || motionConfig === void 0 ? void 0 : motionConfig.keyframes) {
892
+ injectCustomKeyframes(motionConfig.keyframes);
893
+ }
894
+ }, [isHero, font, animation, motionConfig === null || motionConfig === void 0 ? void 0 : motionConfig.keyframes]);
895
+ // ── Re-stamp <em> inline styles after every relevant change ───────────────
795
896
  useEffect(() => {
796
897
  if (!isHero || !animation || !ref.current)
797
898
  return;
798
899
  applyEmStylesDOM(ref.current, italic, accentColor, font);
900
+ if (animation === "thread")
901
+ applyThreadOffsets(ref.current);
799
902
  }, [italic, accentColor, font, animation, isHero]);
903
+ // ── motionRef callback — fires after mount and on every re-render ──────────
904
+ // motionRef wins over animation and motionConfig — the user drives the DOM.
905
+ useEffect(() => {
906
+ if (!motionRef)
907
+ return;
908
+ motionRef(ref.current);
909
+ });
910
+ // ── Strip lingering CSS properties after animation ends ───────────────────
911
+ useEffect(() => {
912
+ const el = ref.current;
913
+ if (!el || !animation)
914
+ return;
915
+ const cleanup = () => {
916
+ if (animation === "maskSweep") {
917
+ el.style.setProperty("mask-image", "none");
918
+ el.style.setProperty("-webkit-mask-image", "none");
919
+ }
920
+ if (animation === "gradSweep") {
921
+ el.style.animation = "none";
922
+ }
923
+ };
924
+ el.addEventListener("animationend", cleanup, { once: true });
925
+ return () => el.removeEventListener("animationend", cleanup);
926
+ }, [animation]);
800
927
  const Tag = (as !== null && as !== void 0 ? as : variantTagMap[variant]);
801
- // ── Animation path: build inner HTML ─────────────────────────────────────
928
+ // ── Build inner HTML — priority: motionRef > motionConfig > animation ──────
802
929
  let animClass = "";
803
930
  let heroHTML = null;
804
- if (animation && isHero) {
931
+ let customAnimStyle;
932
+ // motionRef — no HTML manipulation needed, user handles everything via ref
933
+ if (motionRef) ;
934
+ // motionConfig — works on any variant, not just heroes
935
+ else if (motionConfig === null || motionConfig === void 0 ? void 0 : motionConfig.keyframes) {
936
+ const keyframeName = injectCustomKeyframes(motionConfig.keyframes);
937
+ const duration = (_e = motionConfig.duration) !== null && _e !== void 0 ? _e : "0.8s";
938
+ const easing = (_f = motionConfig.easing) !== null && _f !== void 0 ? _f : "cubic-bezier(0.16,1,0.3,1)";
939
+ const delay = (_g = motionConfig.delay) !== null && _g !== void 0 ? _g : "0s";
940
+ const fillMode = (_h = motionConfig.fillMode) !== null && _h !== void 0 ? _h : "both";
941
+ const split = (_j = motionConfig.split) !== null && _j !== void 0 ? _j : "none";
942
+ const staggerDelay = (_k = motionConfig.staggerDelay) !== null && _k !== void 0 ? _k : (split === "chars" ? 0.04 : 0.07);
943
+ const rawHTML = childrenToHTML(children);
944
+ const { html, baseAnimation } = buildCustomHTML(rawHTML, keyframeName, duration, easing, delay, fillMode, split, staggerDelay);
945
+ if (split === "none") {
946
+ // Apply animation directly on the element via inline style
947
+ customAnimStyle = baseAnimation;
948
+ heroHTML = rawHTML;
949
+ }
950
+ else {
951
+ heroHTML = html;
952
+ }
953
+ }
954
+ // Built-in animation preset
955
+ else if (animation && isHero) {
805
956
  const rawHTML = childrenToHTML(children);
806
957
  if (isSplitAnimation(animation)) {
807
958
  heroHTML = buildSplitHTML(animation, rawHTML);
@@ -812,8 +963,12 @@ const Typography = (_a) => {
812
963
  }
813
964
  }
814
965
  // ── Computed container styles ─────────────────────────────────────────────
815
- const computedStyle = Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({}, variantStyleMap[variant]), (font ? { fontFamily: `'${font}', sans-serif` } : {})), (color ? { color } : {})), (align ? { textAlign: align } : {})), (truncate
816
- ? { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }
966
+ const computedStyle = Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({}, variantStyleMap[variant]), (font ? { fontFamily: `'${font}', sans-serif` } : {})), (color ? { color } : {})), (align ? { textAlign: align } : {})), (truncate
967
+ ? {
968
+ overflow: "hidden",
969
+ textOverflow: "ellipsis",
970
+ whiteSpace: "nowrap",
971
+ }
817
972
  : {})), (maxLines && !truncate
818
973
  ? {
819
974
  display: "-webkit-box",
@@ -821,17 +976,21 @@ const Typography = (_a) => {
821
976
  WebkitBoxOrient: "vertical",
822
977
  overflow: "hidden",
823
978
  }
824
- : {})), { margin: 0, padding: 0 }), style);
825
- // ── Render: animation path (dangerouslySetInnerHTML) ──────────────────────
979
+ : {})), (customAnimStyle ? { animation: customAnimStyle } : {})), { margin: 0, padding: 0 }), style);
980
+ // ── Render: animation path ────────────────────────────────────────────────
981
+ //
982
+ // key={animation} forces React to unmount + remount the element when the
983
+ // animation value changes, guaranteeing the CSS keyframe fires from frame 0
984
+ // on every switch.
826
985
  if (heroHTML !== null) {
827
986
  return (jsx(Tag, Object.assign({ ref: ref, className: [animClass, className].filter(Boolean).join(" "), style: computedStyle, dangerouslySetInnerHTML: { __html: heroHTML } }, rest), animation));
828
987
  }
829
- // ── Render: standard path ────────────────────────────────────────────────
988
+ // ── Render: standard path ─────────────────────────────────────────────────
830
989
  const processedChildren = isHero
831
990
  ? renderChildrenWithEmStyles(children, italic, accentColor, font)
832
991
  : children;
833
992
  return (jsx(Tag, Object.assign({ ref: ref, className: className, style: computedStyle }, rest, { children: processedChildren })));
834
993
  };
835
994
 
836
- export { GOOGLE_FONTS, Typography, TypographyProvider, buildFontUrl, Typography as default, injectFont, preloadFonts };
995
+ export { GOOGLE_FONTS, Typography, TypographyProvider, buildFontUrl, Typography as default, injectFont, preloadFonts, useTypographyTheme };
837
996
  //# sourceMappingURL=index.esm.js.map