PageSourceSearch

https://www.ofnoah.jp/js/of-motion.js

js ofnoah.jp collected 2026-10-07 17:58:14 UTC 14,247 bytes, 241 lines download raw bytes

1/**
2 * Noah motion: scroll reveals, hero entrances and magnetic CTAs for the main pages (home + condition pages, JP and EN).
3 * Loaded only on the pages listed in src/lib/motion.mjs; hover states live in public/css/of-motion.css.
4 *
5 * Same motion language as ordinaryfolk.health and noah-sg (their of-motion.js): one easing curve, Web Animations API,
6 * and the individual `translate` / `scale` properties, which compose with the transforms Webflow, Swiper and GSAP set
7 * instead of overwriting them.
8 *
9 * - Content is never hidden without JS: everything below the fold is hidden by a paused animation created here,
10 *   and the hero is pre-hidden by CSS only while html.ofm-js is set and html.ofm-ready isn't (with a failsafe).
11 * - Sections the pages' own GSAP / Webflow interactions drive are skipped (SKIP, and anything Webflow has hidden
12 *   with an inline opacity:0 for its own entrance).
13 * - prefers-reduced-motion: nothing moves.
14 * - To animate a new block, add a rule to RULES (selectors that aren't on a page are ignored), or to LATE_RULES if
15 *   another script adds the block on DOMContentLoaded.
16 */
17(function () {
18  'use strict';
19  var root = document.documentElement;
20  var ready = function () { root.classList.add('ofm-ready'); };
21  if (!Element.prototype.animate || (window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches)) return ready();
22
23  // Slow load: when this script runs after the CSS failsafe has already shown the hero (3s, see of-motion.css), the page
24  // has been on screen as it is. Don't hide what people can already see: whatever is in view now (or the failsafe
25  // revealed) gets no entrance; content further down still reveals on scroll.
26  var shown = new Set(), slow = false;
27  if (document.getAnimations) document.getAnimations().forEach(function (a) {
28    if (a.animationName === 'ofm-failsafe' && a.playState === 'finished') { slow = true; shown.add(a.effect.target); }
29  });
30  function seen(el) {
31    if (!slow) return false;
32    if (shown.has(el)) return true;
33    var r = el.getBoundingClientRect();
34    return !!(r.width || r.height) && r.bottom > 0 && r.top < innerHeight;
35  }
36
37  var EASE = 'cubic-bezier(.2,.8,.2,1)';
38  var POP = 'cubic-bezier(.34,1.56,.64,1)';
39  // wipe/band clip with a margin (G) so glyphs that overflow the line box are never cut off mid-reveal: Noto Sans JP
40  // at line-height 1 (the home h1) draws Latin descenders ~.15em below the box.
41  var G = '-.25em';
42  var V = {
43    up: { k: [{ opacity: 0, translate: '0 28px' }, { opacity: 1, translate: '0 0' }], d: 800 },
44    rise: { k: [{ opacity: 0, translate: '0 56px', scale: '0.97' }, { opacity: 1, translate: '0 0', scale: '1' }], d: 1000 },
45    left: { k: [{ opacity: 0, translate: '-40px 0' }, { opacity: 1, translate: '0 0' }], d: 800 },
46    pop: { k: [{ opacity: 0, scale: '0.6' }, { opacity: 1, scale: '1' }], d: 650, e: POP },
47    fade: { k: [{ opacity: 0 }, { opacity: 1 }], d: 700 },
48    wipe: { k: [{ clipPath: 'inset(' + G + ' ' + G + ' calc(100% + .25em) ' + G + ')', translate: '0 6%' }, { clipPath: 'inset(' + G + ')', translate: '0 0' }], d: 1100 },
49    band: { k: [{ clipPath: 'inset(' + G + ' calc(100% + .25em) ' + G + ' ' + G + ')' }, { clipPath: 'inset(' + G + ')' }], d: 900 },
50    settle: { k: [{ scale: '1.08' }, { scale: '1' }], d: 1800 }
51  };
52
53  // Left alone: the GSAP scroll stories and pinned sections (inline scripts on the ED / hair loss / weight loss pages),
54  // headers and nav, sticky CTAs, Webflow marquees (.marquee, .moving-carousel), popups, tabs, footer.
55  var SKIP = '.ed-animated-section, .why-noah-how-it-works, #tired-section, #plan-section, #doctor-section, ' +
56    '#tired-sticky, #plan-sticky, #doctor-sticky, #why-noah-sticky, .pin-spacer, .navbar-holder, header, nav, ' +
57    '.ed-cta-wrapper, .section_sticky-cta, .condition_cta, .marquee, .moving-carousel, [class*="popup"], ' +
58    '[class*="pop-up"], [class*="pop_up"], .w-tab-content, footer';
59  var IX_HIDDEN = '[style*="opacity:0"], [style*="opacity: 0"]';
60
61  // [selector, variant, options]. stagger: animate the element's children one after another (kids filters them);
62  // delay / step in ms. Order matters only in that an element is animated by the first rule that matches it.
63  var RULES = [
64    // Heroes (on screen at load)
65    ['.home-page-hero-sec :is(.hero-text-wrapper, .hero-text-wrapper-home-ja) > h1', 'wipe', { delay: 50 }],
66    ['.home-page-hero-sec :is(.hero-text-wrapper, .hero-text-wrapper-home-ja)', 'up', { stagger: true, kids: ':not(h1)', delay: 300 }],
67    ['.home-page-hero-sec .category-sliders', 'rise', { stagger: true, delay: 400, step: 110 }],
68    ['.ed-hero-container .slide:not(.small)', 'wipe', { delay: 150 }],
69    ['.ed-hero-container .slide.small:only-child', 'up', { stagger: true, delay: 150, step: 100 }], // JP hair loss: no headline
70    ['.ed-hero-container .slide.small', 'up', { stagger: true, delay: 500, step: 100 }],
71    ['.wl-badge-absolute', 'pop', { delay: 800 }],
72    ['.hero-bg, .hl-hero-bg, .wl-hero-bg', 'settle'],
73    ['[class^="ed-hero-col"]', 'up', { stagger: true, delay: 100, step: 90 }],
74    ['.pe-card_component.is-pe', 'rise', { delay: 50 }],
75    ['.pe-card_component .ed-card_subtitle', 'up', { delay: 200 }],
76    ['.pe-card_component .ed-card_content > .max-width-custom', 'wipe', { delay: 260 }],
77    ['.pe-card_component .card_dots', 'band', { delay: 500 }],
78    ['.pe-card_component .banner_feature_list', 'up', { stagger: true, delay: 520, step: 90 }],
79    ['.pe-card_component > .z-index-2', 'up', { delay: 720 }],
80
81    // Home
82    ['.category-info-wrapper, .category-info-wrapper-black-text', 'up', { stagger: true, step: 110 }],
83    ['.category-med-slider-wrapper > :first-child', 'up', { delay: 150 }],
84    ['.condition-cards-wrapper', 'rise', { stagger: true, delay: 200, step: 100 }],
85    ['.custom-container-new.simplified > .hero-text-wrapper', 'up', { stagger: true, step: 120 }],
86    ['.simplified-card', 'rise'],
87    ['.simplified-text-wrapper', 'up', { stagger: true, delay: 250, step: 110 }],
88
89    // Condition pages (ED, hair loss, weight loss; same template in JP and EN)
90    ['.desc .offset-110px', 'up', { stagger: true, step: 120 }],
91    ['.medication-section .med-card-new', 'up', { delay: 100 }],
92    ['.hard-enough-con .left-align-container', 'up', { stagger: true, kids: ':not(.ctas-wrapper)', step: 110 }],
93    ['.ctas-wrapper', 'left', { stagger: true, delay: 300, step: 80 }],
94    ['.hl-content-wrapper > :first-child', 'up'],
95    ['.hl-content-wrapper .hl-bullets', 'up', { stagger: true, delay: 150, step: 80 }],
96    ['.plan-sec .t-left-col', 'up', { stagger: true, step: 110 }],
97    ['.t-right-col-plan > .swiper-wrapper', 'rise', { stagger: true, delay: 200, step: 110 }],
98    ['.mj-lp-sec > a', 'rise'],
99    ['.personalized .flex-box-28px-gap', 'up', { stagger: true, step: 120 }],
100    ['.personalized .bullet-wrapper', 'up', { stagger: true, delay: 250, step: 80 }],
101    ['.expect-tt-holder', 'up', { stagger: true, step: 120 }],
102    ['.expect-flex', 'rise', { stagger: true, delay: 150, step: 120 }],
103    ['[class^="bmi-calculator-block"]', 'up', { stagger: true, step: 100 }],
104    ['[class^="bmi_calculator-graph-wrapper"]', 'rise', { delay: 200 }],
105    ['.faq-title-flexbox', 'up'],
106    ['.new-faq-ed_list', 'up', { stagger: true, step: 70 }],
107    ['.extra-question', 'up', { delay: 200 }],
108    ['.ceo-speech .offset-100px', 'up', { stagger: true, step: 160 }],
109
110    // English premature ejaculation (older template, same as noah-sg's)
111    ['.sing-men-stat', 'pop'],
112    ['.stopwatch', 'rise', { delay: 150 }],
113    ['.hair-loss-stats_text', 'up', { stagger: true, step: 110 }],
114    ['.never-treatment-wrapper', 'up', { stagger: true, step: 120 }],
115    ['.orange-stats-wrapper', 'rise', { stagger: true, step: 110 }],
116    ['.pill-tab', 'up'],
117    ['.here-expect-width-50', 'up'],
118    ['.icons-grid', 'pop', { stagger: true, kids: ':not([class*="dotted-line"])', delay: 150, step: 130 }],
119    ['.icons-grid > [class*="dotted-line"]', 'band', { delay: 100 }],
120    ['[class^="_3-expect-grid_right"]', 'up', { stagger: true, delay: 250, step: 100 }],
121    ['.faq-two-col > .col-50:first-child', 'up', { stagger: true, step: 120 }],
122    ['.faq-two-col > .col-50.padding', 'up', { stagger: true, step: 70 }],
123
124    // Japanese premature ejaculation (its own template)
125    ['.logo-moving-section .container-large', 'up'],
126    ['.pe_treat_benef_content', 'up', { stagger: true, step: 120 }],
127    ['.pe_treat_benef_img', 'rise', { delay: 150 }],
128    ['.teal_dotted_line', 'band', { delay: 200 }],
129    ['.pe_meds-component .info_content-item > :not(.w-dyn-list)', 'up'],
130    ['.pe_meds-component ._4-col-med-list', 'rise', { stagger: true, delay: 150, step: 120 }],
131    ['.examination_top > :is(.text-align-center, .flex-center-align)', 'up'],
132    ['.examination_top .med_exam_step_wrapper', 'rise', { delay: 100 }],
133    ['.examination_top > .button-wrapper', 'up', { stagger: true, step: 120 }]
134  ];
135
136  // Same, for blocks other scripts add on DOMContentLoaded (after this script has run): the JP "安全に使うために"
137  // section that jp23ed/hl/wl-sideeffects.js add to the ED, hair loss and weight loss pages, and the fee note
138  // (of-jp-fee-note.js; on ED / hair loss it sits inside .med-card-new, which already moves).
139  var LATE_RULES = [
140    ['.jp23ed-safely-head', 'up', { stagger: true, step: 120 }],
141    ['.jp23ed-safely-grid', 'rise', { stagger: true, delay: 150, step: 120 }],
142    ['.plan-sec > .of-fee-note', 'up']
143  ];
144
145  var claimed = new WeakSet();
146  var triggers = new Map(); // trigger element -> animations to play when it scrolls into view
147
148  function usable(el) {
149    return !claimed.has(el) && !el.closest(SKIP) && !el.closest(IX_HIDDEN) && !el.querySelector(IX_HIDDEN);
150  }
151
152  function prep(el, variant, delay, trigger) {
153    if (!usable(el) || seen(el)) return;
154    claimed.add(el);
155    var v = V[variant];
156    var a = el.animate(v.k, { duration: v.d, easing: v.e || EASE, delay: delay || 0, fill: 'both' });
157    a.pause();
158    // Hand the element back to its own CSS (hover transitions, Webflow interactions) once it's in place.
159    a.onfinish = function () { a.cancel(); };
160    if (!triggers.has(trigger)) triggers.set(trigger, []);
161    triggers.get(trigger).push(a);
162  }
163
164  function apply(rules) {
165    try {
166      rules.forEach(function (rule) {
167        var o = rule[2] || {};
168        var els;
169        try { els = document.querySelectorAll(rule[0]); } catch (e) { return; } // selector this browser can't parse
170        els.forEach(function (el) {
171          if (!o.stagger) return prep(el, rule[1], o.delay, el);
172          if (el.closest(SKIP)) return;
173          var kids = Array.prototype.filter.call(el.children, function (c) { return !o.kids || c.matches(o.kids); });
174          kids.forEach(function (c, i) { prep(c, rule[1], (o.delay || 0) + Math.min(i, 8) * (o.step || 90), el); });
175        });
176      });
177    } catch (e) {
178      // Never leave anything hidden: show whatever was already prepared.
179      triggers.forEach(function (anims) { anims.forEach(function (a) { a.cancel(); }); });
180      triggers.clear();
181    }
182  }
183
184  try { apply(RULES); } finally { ready(); }
185
186  // Scroll reveals: rect-based rather than IntersectionObserver, so swiper slides that start off to the side
187  // still reveal with their row, and clip-path doesn't hide a target from its own trigger.
188  var raf = 0, visible = false, listening = false;
189  function check() {
190    raf = 0;
191    var line = window.innerHeight * 0.88;
192    triggers.forEach(function (anims, t) {
193      var r = t.getBoundingClientRect();
194      if (r.top < line || (!r.width && !r.height)) {
195        anims.forEach(function (a) { a.play(); });
196        triggers.delete(t);
197      }
198    });
199    if (!triggers.size && listening) { listening = false; removeEventListener('scroll', onScroll); removeEventListener('resize', onScroll); }
200  }
201  function onScroll() { if (!raf) raf = requestAnimationFrame(check); }
202  function start() {
203    if (!listening && triggers.size) { listening = true; addEventListener('scroll', onScroll, { passive: true }); addEventListener('resize', onScroll); }
204    check();
205  }
206
207  // VWO hides the whole body until its script arrives (up to 2s); start once the page can actually be seen. VWO first
208  // empties the hide rule and removes the <style> a moment later, so check the body itself.
209  function hiddenByVwo() {
210    return !!document.getElementById('_vis_opt_path_hides') && +getComputedStyle(document.body).opacity < 0.05;
211  }
212  function whenVisible(fn) {
213    if (!hiddenByVwo()) return fn();
214    var done = false;
215    var go = function () { if (!done) { done = true; mo.disconnect(); fn(); } };
216    var mo = new MutationObserver(function () { if (!hiddenByVwo()) go(); });
217    mo.observe(document.head, { childList: true, subtree: true, characterData: true });
218    setTimeout(go, 2500);
219  }
220  whenVisible(function () { visible = true; start(); });
221
222  // Their DOMContentLoaded handlers were registered before this one, so the blocks exist by the time it runs. (A defer
223  // script runs while readyState is already "interactive", before DOMContentLoaded; `load` is the fallback.)
224  var lateDone = false;
225  function late() { if (lateDone) return; lateDone = true; apply(LATE_RULES); if (visible) start(); }
226  if (document.readyState === 'complete') late();
227  else { document.addEventListener('DOMContentLoaded', late); addEventListener('load', late); }
228
229  // Magnetic primary CTAs (mouse / trackpad only).
230  if (window.matchMedia && matchMedia('(hover: hover) and (pointer: fine)').matches) {
231    document.querySelectorAll('.category-cta, .ed-cta-button, [class^="button-orange"], [class*=" button-orange"], ' +
232      ':is(.section_hero-image, .section_med_cards, .section_examination) .button').forEach(function (el) {
233      el.style.transition = (el.style.transition ? el.style.transition + ', ' : '') + 'translate 350ms ' + EASE;
234      el.addEventListener('pointermove', function (e) {
235        var r = el.getBoundingClientRect();
236        el.style.translate = ((e.clientX - r.left - r.width / 2) * 0.22).toFixed(1) + 'px ' + ((e.clientY - r.top - r.height / 2) * 0.35).toFixed(1) + 'px';
237      });
238      el.addEventListener('pointerleave', function () { el.style.translate = ''; });
239    });
240  }
241})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.