Engine
כל שינוי כאן ישפיע על האתר כולו.A plain Next.js install, and only what we did differently on top of it. How Next itself works is in its own docs. What runs on every page load and every route change without a line in the page: the boot order, the click rules, the current-page mark, route popups, the focus ring, a demo's body class (UkBodyClass) and Lenis. Read from es-scripts.
עדכון אחרון: 7.11.2026
#add-code
Add code to every page
Code that runs on every page goes in one of the files below. Do not add a new component to app/layout.tsx for it: that way everything that runs at startup, and its order, stays in one place.
// es-scripts/_init/uk_global_init.tsx
// once, on the first paint: in the "Mount-once setup" useEffect
console.log('hello')
// on every route change: in the useEffect that depends on [pathname]
console.log('hello', pathname)
// an animation or a slider: es-scripts/uk-global-scripts.ts, initGlobalAnimations()
// one import + one try { my_init() } catch {} line. The order matters
// a third-party tag (GA, a pixel): components/studio/SiteTags.tsx#one-or-all
One page or the whole site
Page settings and site settings, in Webflow words. A library imported by a component is sent only to the pages that use that component, so one page costs the others nothing.
One page (page settings)
// components/uikit/UkGallery/UkGallery.tsx
'use client'
export default function UkGallery({ children }) {
const box = useRef(null)
useEffect(() => {
let grid
// on the client only, when the page opens
import('masonry-layout').then(({ default: Masonry }) => {
grid = new Masonry(box.current, { itemSelector: 'figure' })
})
return () => grid?.destroy()
}, [])
return <div ref={box}>{children}</div>
}
// app/gallery/page.tsx: the only page that gets it
<UkGallery>…</UkGallery>The real example: components/fancybox/carousel_autoscroll/CarouselAutoscroll.tsx.
The whole site (site settings)
// es-scripts/masonry_init.ts
export async function masonry_init() {
const box = document.querySelector('[data-masonry]')
if (!box) return // a page without it: nothing, and nothing loads
const { default: Masonry } = await import('masonry-layout')
new Masonry(box, { itemSelector: 'figure' })
}
// es-scripts/uk-global-scripts.ts
import { masonry_init } from './masonry_init'
// in initGlobalAnimations()
try { masonry_init() } catch {}It runs on every page and every route change, so it needs a matching destroy in destroyGlobalAnimations(). The check at its top is what keeps the cost off other pages: a library imported at the top of the file would go to every page, used or not.
#global-init
UkGlobalInit
<Component />es-scripts/_init/uk_global_init.tsx: a client component that renders nothing, mounted once in app/layout.tsx, so it runs on every page, the studio and every demo. It is the one place for code that runs on every load and every route change (CLAUDE.md 1.2).
Once, on the first paint
setupResizeListener(): 250ms after the last resize, every ScrollTrigger is sorted and refreshed.fancybox_init(): binds[data-fancybox]for the whole site, in the page's direction (RTL), with the Lenis hand-off (see Lenis) and the Back handling (popup_history.ts, see Route popup). It also sets the Hebrew labels, a counter that reads correctly in RTL, no fullscreen button, no drag on HTML popups, and names the dialog from its bar title or first heading. Fancybox's CSS loads after the page is up, off the first paint.Fancyboxfocus_quiet_init(): the focus ring rule (Focus quiet).- One
ScrollTrigger.refresh()onwindowload: late content (a carousel that injects its slides) moves sections after the first measure. - The
popstatelistener, and a click listener ondocumentin the capture phase (Clicks and Back). window.__ukNavigate, the router for plain scripts.
On every route change
At once on every change: ?lines draws a 24px rule over the page and ?lines=0 takes it off (uk-lines-view, data-lines-view). ?seo turns the SEO view on and ?seo=0 off (uk-seo-view in sessionStorage, data-seo-view on <html>, the volume badge of DevVolume), and it stays on from page to page. Then, when the pathname changes (and on the first load), 50ms later:
initGlobalAnimations(), in the order below.update_active_links(): the.is-currentmark.gsap:initonwindow.<html data-uk-ready>, even if a step threw.- A forward navigation scrolls to the top, through Lenis when it is on (
scrollTo(0, { immediate: true })). Back and Forward do not: the browser puts the reader back by itself.
A navigation is a change of path, compared with the last one, not a first-mount flag: React runs the effect twice in dev, and a flag took every reload for a navigation. A ?query or #hash change keeps the path: no new init.
- <html data-uk-ready>
- …
- window.__ukCameFrom
- …
__ukCameFrom names the page you came from.Code: rebuild on gsap:init
'use client'
import { useEffect } from 'react'
// a component that builds its own ScrollTrigger: the global init kills
// every trigger on each route change, then fires gsap:init
useEffect(() => {
const build = () => { /* gsap.to(el, { scrollTrigger: { … } }) */ }
window.addEventListener('gsap:init', build)
build()
return () => window.removeEventListener('gsap:init', build)
}, [])What it gives the page
gsap:initwindow eventFired after every init, once the page is set. Listened to by every component that builds its own ScrollTrigger, by UkPageLoader (it pauses Lenis again) and by UkPageTransitions (its arrival).data-uk-readyattribute on <html>Set after the first init, in afinally, so a step that threw does not hold it back. Until then[trigger_box]elements are still hidden. UkLiveFrame waits for it before it lifts its UkLoader.window.__ukNavigate(href: string) => voidThe Next router (router.push) for plain scripts:lib/follow-link.ts(the hand-built slides of SyncSlidersClassic and SyncWithVerticalText) and UkPageTransitions navigate client side through it.window.__ukCameFromstringThe path a client-side navigation came from. UkLiveFrame's X goes back there.
| Name | Type | Default | What |
|---|---|---|---|
gsap:init | window event | - | Fired after every init, once the page is set. Listened to by every component that builds its own ScrollTrigger, by UkPageLoader (it pauses Lenis again) and by UkPageTransitions (its arrival). |
data-uk-ready | attribute on <html> | - | Set after the first init, in a finally, so a step that threw does not hold it back. Until then [trigger_box] elements are still hidden. UkLiveFrame waits for it before it lifts its UkLoader. |
window.__ukNavigate | (href: string) => void | - | The Next router (router.push) for plain scripts: lib/follow-link.ts (the hand-built slides of SyncSlidersClassic and SyncWithVerticalText) and UkPageTransitions navigate client side through it. |
window.__ukCameFrom | string | - | The path a client-side navigation came from. UkLiveFrame's X goes back there. |
No props. One instance, in app/layout.tsx.
Rules
- Code that must run on every page or route change goes inside
uk_global_init.tsx. Never a parallel<MyInit />mounted inapp/layout.tsx(CLAUDE.md 1.2). - An animation or slider init goes into
initGlobalAnimations()ines-scripts/uk-global-scripts.ts: one import and onetry { … } catch {}line. - A component that builds its own ScrollTrigger builds on mount AND on
gsap:init: the init kills every trigger after the page's own effects ran.
Live in
- Every page · this one included
- /works/business-coach · UkLiveFrame waits for
data-uk-readyinside the frame
#init-order
initGlobalAnimations: the order
What every route change runs, as es-scripts/uk-global-scripts.ts calls it. This list is read from that file when the page is built (app/docs/global/_init-order.ts), so it cannot drift. Every script skips itself when its DOM marker is not on the page, and each script call sits in its own try: one that throws does not stop the rest (the teardown and the two refreshes are not wrapped).
- The numbers are only the order in the file. Only the steps below are in that place for a reason. Inside a group (the scroll animations, the Swipers) the order means nothing: each step looks for its own attribute and does nothing on a page without it.
destroyGlobalAnimations()first: kills every ScrollTrigger and its scroll memory, stops video autoplay and the mouse-move effect, and clears the body theme (its observer and theuk-body-*classes on<body>). Not Lenis: one instance lives across routes.lenis_init()right after, before any ScrollTrigger is built: the triggers follow Lenis's scroll (lenis.on('scroll', ScrollTrigger.update)). Don't reorder.refreshScrollTriggers():ScrollTrigger.sort()+refresh(), then.gsap-readyon every.opacity_0_on_load. Once between the animations and the sliders, once more on the next frame, for layout that moved during the init.
- 1
destroyGlobalAnimationsGSAP - 2
lenis_initGSAPes-scripts/LENIS/uk-lenis-init.js - 3
trigger_box_animationGSAPes-scripts/GSAP/trigger_box_animation.js - 4
split_text_animation_initGSAPes-scripts/GSAP/split_text_animation.js - 5
countUpGSAPes-scripts/GSAP/count_up_animation.js - 6
slot_counter_animationGSAPes-scripts/GSAP/slot_counter_animation.js - 7
clip_reveal_animationGSAPcomponents/GSAP/ClipReveal/clip_reveal_animation.js - 8
parallax_animationGSAPes-scripts/GSAP/parallax_animation.js - 9
section_clip_animationGSAPes-scripts/GSAP/section_clip_animation.js - 10
fit_to_target_animationGSAPes-scripts/GSAP/fit_to_target_animation.js - 11
movement_animationGSAPes-scripts/GSAP/movement_animation.js - 12
rotate_x_stack_animationGSAPes-scripts/GSAP/rotate_x_stack_animation.js - 13
shade_overlay_animationGSAPes-scripts/GSAP/shade_overlay_animation.js - 14
mouse_move_on_elementGSAPes-scripts/GSAP/mouse_move_on_element.js - 15
cover_hero_animationGSAPes-scripts/GSAP/cover_hero_animation.js - 16
blur_bottom_animationGSAPes-scripts/GSAP/blur_bottom_animation.js - 17
cta_sticky_animationGSAPes-scripts/GSAP/cta_sticky_animation.js - 18
scrolling_text_animationGSAPes-scripts/GSAP/scrolling_text_animation.js - 19
video_autoplay_animationGSAPes-scripts/GSAP/video_autoplay_animation.js - 20
body_themeGSAPes-scripts/GSAP/body_theme.js - 21
ground_fade_animationGSAPes-scripts/GSAP/ground_fade_animation.js - 22
zoom_out_nine_photos_grid_animationGSAPcomponents/GSAP/ZoomOutNinePhotosGrid/zoom_out_nine_photos_grid_animation.js - 23
grid_assemble_animationGSAPcomponents/GSAP/GridAssemble/grid_assemble_animation.js - 24
puzzle_on_scrollGSAPes-scripts/GSAP/puzzle_on_scroll.js - 25
refreshScrollTriggersGSAP - 26
scale_active_swiper_initSwipercomponents/Swiper/creative_scale_active/creative_scale_active_swiper - 27
classic_grid_swiper_initSwipercomponents/Swiper/classic_grid/classic_grid_swiper - 28
swiper_tabs_slider_initSwipercomponents/Swiper/swiper_tabs_slider/swiper_tabs_slider_swiper - 29
sync_slider_initSwipercomponents/Swiper/sync_sliders_classic/sync_sliders_classic_swiper - 30
sync_with_vertical_text_initSwipercomponents/Swiper/sync_with_vertical_text/sync_with_vertical_text_swiper - 31
swiper_sub_menu_initSwipercomponents/Swiper/swiper_sub_menu/swiper_sub_menu - 32
fashion_slider_initSwipercomponents/Swiper/ui-initiative/fashion_slider/fashion-slider-swiper - 33
super_flow_swiper_initSwipercomponents/Swiper/ui-initiative/super_flow/super-flow-swiper - 34
material_you_swiper_initSwipercomponents/Swiper/ui-initiative/material_you_slider/material-you-swiper - 35
expo_slider_initSwipercomponents/Swiper/ui-initiative/expo_slider/expo-slider-swiper - 36
shaders_slider_initSwipercomponents/Swiper/ui-initiative/shaders_slider/shaders-slider-swiper - 37
trigger_slide_animationGSAPes-scripts/GSAP/trigger_slide_animation.js - 38
refreshScrollTriggersGSAP · next frame
#click-rules
Clicks and Back
One click listener on document, in the capture phase, so it runs before any <Link> or component handler. It reads the clicked <a>'s own href attribute and applies the first rule that fits:
- Left to the browser: no
href, one that starts withhttp,mailto:ortel:, an in-page#anchor,target="_blank"ordownload. - A route popup: a plain left click (no Cmd, Ctrl, Shift, Alt) on a path that opens as a popup on this page, unless the link carries
data-no-popup: that one goes to the page itself (the "צור קשר" of the studio footer and mobile menu).preventDefault+stopPropagation, and the popup opens (Route popup). Other capture listeners ondocument(SiteTags' conversion tags) still hear it; a bubbling one does not. - A demo from the studio: the href starts with
/demos/and this page is not under/demos.window.open(href, '_blank', 'noopener'); a modified click is the browser's. Inside a demo its own links stay in the tab. ESLint'suk/demo-link-new-tabasks fortarget="_blank"where it can read the href; this covers hrefs from data. - The same page: no
#in the href, a plain click, and both its path and its?queryare the current ones. A link to the same path with another query is another page (another home of a demo), not this one. Straight to the top (Lenisimmediate). A<Link>to the page you are on does nothing in Next; this is the "click the page you are on, go up" of a normal site, for<Link>and plain<a>alike. - A link to the path you are on that rule 4 did not take (another
?query, a#hash, a modified click): nothing, the page stays mounted and no trigger is killed. Any other internal link:destroyGlobalAnimations()before Next navigates, so the old page's triggers go with it.
Back and Forward (popstate): popup_history_back() (es-scripts/_init/popup_history.ts) first. If the entry was a popup's (any Fancybox popup), the popup closes and that is all: same page. Otherwise the next route change is marked as Back (no scroll to the top, the saved offset comes back) and destroyGlobalAnimations() runs before the page swaps.
Known gap (17.10): rule 5 also takes a Cmd or Ctrl click on a link to another path (the page stays, the link opens in a new tab). The triggers are killed and, since the path does not change, nothing rebuilds them until the next navigation: a [data-movement] on /docs/gsap/scroll-depth stops moving. Back or Forward between two #hash entries of one page (the hero's "On this page" pills) does the same: the popstate handler kills the triggers and the path does not change.
- This pagerule 4 · same path: back to the top
- /contactrule 2 · a route popup: the contact popup opens
- /demos/yogarule 3 · a demo from the studio: a new tab
- /docs/navbarrule 5 · any other page: triggers killed, Next navigates
Code: which rule a link gets
<Link href="/about">אודות</Link> // rule 5 (rule 4 on /about itself)
<Link href={demo.url}>{demo.title}</Link> // from data: rule 3, a new tab
<Link href="/demos/yoga" target="_blank" rel="noopener">יוגה</Link> // rule 1
<a href="https://wa.me/…" target="_blank" rel="noopener noreferrer">וואטסאפ</a> // rule 1Rules
- Internal links are
<Link>(CLAUDE.md 1.4): on a plain<a href="/…">the rules still run, then the browser reloads the page. - A component that navigates on its own (a slide built by hand) goes through
followLink()(lib/follow-link.ts), which callswindow.__ukNavigate, notwindow.location. - A demo link written out in the studio carries
target="_blank" rel="noopener"(ESLintuk/demo-link-new-tab); rule 3 is the net for hrefs that come from data.
Live in
- The studio header and footer · "צור קשר" in the header is rule 2, in the footer it goes to the page (data-no-popup), the page you are on is rule 4
- The "Live in" lists of /docs/headers · rule 3: demo hrefs from a data array, each opens in a new tab
#active-link
Active link: .is-current
[attribute]Every <a href> that leads to the page you are on carries the class is-current: the navbar, the mobile menu, the footer, a sidebar, a link in the copy. update_active_links() in es-scripts/_init/active_link_marker.js puts it on and takes it off, on every route change. No component computes it.
When to use
Any "you are here" look outside the navbar's desktop items: style .is-current in that scope. The navbar's own items also carry aria-current="page" from UkNavbar, with a prefix match the class does not have; both are styled together (/docs/navbar#nav-current).
- /docs/globalthis page
- /docs/global?view=alla query: only the pathname is compared
- /docsthe parent: exact match, not marked
- /docs/navbaranother page
a.is-current in its own CSS so the mark shows.Code
/* any scope: a footer, a sidebar, the copy */
.my-footer a.is-current { color: var(--primary); }
/* uk-navbar-colors.css: the navbar's own, both marks together */
.uk-nav-item[aria-current="page"],
.uk-nav-item.is-current,
.uk-mobile-nav a.is-current:not(.uk-button) { … }The two marks
.is-currentclass on <a>Set byupdate_active_links()(es-scripts/_init/active_link_marker.js) on everya[href]whose pathname equals this one, trailing slash ignored. Skips#…,mailto:,tel:and another origin.aria-current="page"attributeSet by UkNavbar on its desktop items only. Prefix match (/serviceson/services/x), exact when a sibling item sits under it; an item without href is current when a child matches.activeForadds paths outside its folder,inactiveForremoves paths. A plain dropdown child gets it on an exact match.
| Mark | Type | Default | What |
|---|---|---|---|
.is-current | class on <a> | - | Set by update_active_links() (es-scripts/_init/active_link_marker.js) on every a[href] whose pathname equals this one, trailing slash ignored. Skips #…, mailto:, tel: and another origin. |
aria-current="page" | attribute | - | Set by UkNavbar on its desktop items only. Prefix match (/services on /services/x), exact when a sibling item sits under it; an item without href is current when a child matches. activeFor adds paths outside its folder, inactiveFor removes paths. A plain dropdown child gets it on an exact match. |
Rules
- Style the current page through
.is-currentin your scope; never compare the pathname in a component. - The class is visual only. A menu of your own that needs
aria-currentfor a screen reader sets it itself (WebflowTabs and WebinyCourseSidebar do). - It runs once per route change: a link that mounts afterwards is not marked until the next one.
Live in
- Every navbar: its items and its mobile menu · the studio footer's link gets the class too, but nothing styles it there
- Navbar: the current page · aria-current and the indicator line
#route-popup
Route popup: data-popup-for
[attribute]A route that opens as a popup: a click on a link to /contact opens the contact popup over the page instead of leaving it, and /contact stays a real page for Google, a shared link and a new tab. es-scripts/_init/route_popup.ts, called by the click handler (rule 2).
- The popup declares its route: a hidden Fancybox trigger with
data-popup-for="/contact". A plain click on an internal link to that path, on a page that has the trigger and is not that path, with no#hashand nodata-no-popup, clicks the trigger instead. - An open menu closes (
uk-navbar:close). A link clicked in#uk-mobile-navmoves focus to the menu button first, so the dialog gives it back there on close. - Like every Fancybox popup, it gets a history entry of its own (
es-scripts/_init/popup_history.ts): the same URL,{ ...history.state, ukPopup: true }. Back closes it with no page change under it. Closed any other way (X, Esc, the backdrop), the entry is taken off withhistory.back(), and that popstate is ignored too.
Code: another route as a popup
import FancyboxHtml from '@/components/fancybox/FancyboxHtml/FancyboxHtml'
export default function PricingPopup() {
return (
<>
{/* the route's trigger: route_popup.ts clicks it */}
<a href="#pricing-popup" data-fancybox="" data-popup-for="/pricing"
hidden aria-hidden="true" tabIndex={-1} />
<FancyboxHtml id="pricing-popup" title="מחירים">…</FancyboxHtml>
</>
)
}Attributes
data-popup-forstring (a pathname)requiredOn the hidden[data-fancybox]trigger: the path whose links open this popup. Matched exactly against the clicked link's pathname.href"#<popup id>"requiredThe trigger points at the FancyboxHtmlid, like any Fancybox trigger.data-no-popupboolean attributedefaultoffOn a link: it goes to the page, not the popup.
| Attribute | Type | Default | What |
|---|---|---|---|
data-popup-for | string (a pathname) | required | On the hidden [data-fancybox] trigger: the path whose links open this popup. Matched exactly against the clicked link's pathname. |
href | "#<popup id>" | required | The trigger points at the FancyboxHtml id, like any Fancybox trigger. |
data-no-popup | boolean attribute | off | On a link: it goes to the page, not the popup. |
Rules
- One
data-popup-forper path on a page: route_popup.ts clicks the first match only (document.querySelector). Keep the trigger'sdata-fancyboxempty: an empty value opens only its own target. - Mount the popup where its links are. ContactPopup sits in
components/studio/Layout.tsx, which renders a demo's children as they are: a demo never gets it, and its contact links go to its own page. - Keep the route a page. The popup is how a click reaches it, not a replacement for it.
Live in
- Any studio page · the "צור קשר" in the header; the footer one goes to the page
- /contact · the page itself: on its own path no popup opens
#focus-quiet
Focus quiet: data-focus-quiet
[attribute]Every dialog on the site (Fancybox, the Radix menus, the popups) gives focus back to the element that opened it: a keyboard user has to land where they were. The browser draws the ring (:focus-visible) from the last input, and after Esc that was a key, so a mouse user who opened a gallery and closed it with Esc got a ring round the gallery. es-scripts/_init/focus_quiet.ts, mounted once:
- Opened from the keyboard (Enter or Space): the ring shows, as the browser decides.
- Opened with the pointer and closed with Esc: the element that gets focus in the next 1500ms (
ESC_WINDOW) is marked[data-focus-quiet], and[data-focus-quiet][data-focus-quiet]:focus-visible { outline: none }hides it. - The first Tab clears every mark: from there it is keyboard navigation and the ring is back.
- focus
- …
- :focus-visible
- …
- [data-focus-quiet]
- …
- ring
- …
Attributes
data-focus-quietattribute (set by the script)On the element focus came back to, after a pointer-opened dialog closed with Esc. Hides its:focus-visibleoutline. Cleared by the first Tab, and when focus leaves it.
| Attribute | Type | Default | What |
|---|---|---|---|
data-focus-quiet | attribute (set by the script) | - | On the element focus came back to, after a pointer-opened dialog closed with Esc. Hides its :focus-visible outline. Cleared by the first Tab, and when focus leaves it. |
Rules
- Nothing to add: it covers every dialog. Never write
data-focus-quietby hand. - Draw a focus ring as an
outlineon:focus-visible. The quiet rule removes the outline only: a ring drawn withbox-shadowwould still show after Esc.
Live in
- Fancybox popups and galleries
- The contact popup · the "צור קשר" in the desktop header
#uk-body-class
UkBodyClass
<Component />A demo's classes on <body> while the demo is open. Radix (the navbar's dropdowns and drawer), Fancybox and the cursor dot render onto <body>, outside the [data-demo] scope, so they read the brand's font and tokens from body.demo-<brand> in its CSS (CLAUDE.md §11). The classes go on when the layout mounts and come off when it unmounts, so nothing outlives the demo. Engine, not a piece: it shows nothing, it makes the demo's look reach what is rendered outside it.
Code
import UkBodyClass from '@/app/demos/_shared/UkBodyClass'
// app/demos/yoga/layout.tsx: next to data-demo
<div data-demo="yoga">
<UkBodyClass classes="demo-yoga" />
<YogaNavbar />
{children}
<Footer />
</div>
// a variant's skin rides in the same string (catania, v/[variant]/layout.tsx)
<UkBodyClass classes={`demo-catania demo-catania--${variant.slug}`} />
/* yoga.css, its two body.demo-yoga blocks (abridged): what the portalled UI reads, outside [data-demo] */
[data-demo="yoga"],
body.demo-yoga {
--font-sans: 'Noto Sans Hebrew', system-ui, sans-serif;
--font-display: var(--font-sans);
}
…
body.demo-yoga {
font-family: var(--font-sans);
--popover: #f7f1e6;
…
}Props
classesstringrequiredOne or more classes, space-separated:demo-<brand>first, then a variant's own. Added to<body>on mount, taken off on unmount and before a new string replaces them.
| Prop | Type | Default | What |
|---|---|---|---|
classes | string | required | One or more classes, space-separated: demo-<brand> first, then a variant's own. Added to <body> on mount, taken off on unmount and before a new string replaces them. |
Rules
- Once per demo, next to
data-demoin the demo'slayout.tsxwhen every home wears the same class. When a home adds its own class, put it in each home's chrome instead:(base)/layout.tsx+v/[variant]/layout.tsx(blog, catania, dentist) or the shared ConfChrome / SaasChrome. Never in a page. - A variant's class goes in the same string, not in a second UkBodyClass.
- Only what a portal needs goes on
body.demo-<brand>(the font, the popover and ring tokens); the rest stays in the[data-demo]block. Never:root: it would outlive the demo.
Live in
- Yoga ·
demo-yoga, in the demo'slayout.tsx(the usual shape) - Catania, the sushi home ·
demo-catania demo-catania--sushi: the restaurant's skin on its dropdowns - Conference, tourism ·
demo-conference demo-conference--tourism demo-conference--light, from ConfChrome
#lenis-scroll
Lenis
Lenis smooths the wheel on every page: lenis_init() in es-scripts/LENIS/uk-lenis-init.js, the first step of every init after the teardown. One instance (window.lenis), kept across route changes. Options: duration: 0.8, wheelMultiplier: 1, touchMultiplier: 2, an exponential ease, infinite: false. Its scroll drives ScrollTrigger, and GSAP's ticker drives Lenis (lagSmoothing(0)).
smoothWheel follows the visitor: under prefers-reduced-motion the wheel scrolls natively with no glide, and Lenis still runs for ScrollTrigger and lenis.scrollTo. The Lenis 1.3 defaults it keeps (syncTouch: false, allowNestedScroll: false) mean two things: touch scrolling on a phone stays native, and a box in the page with its own scroll does not get the mouse wheel. Lenis takes it and scrolls the page, unless the box says otherwise.
Native scroll where it is needed
- A box in the page scrolls on its own (a wide table, a list, a log)
data-lenis-preventon the element that scrollsLenis lets wheel and touch over it through to the browser. Narrower:data-lenis-prevent-wheel,-touch,-vertical,-horizontal. - A whole page needs native scroll (a sidebar with its own scroll)
<UkLenisOff />anywhere in the pagelenis_destroy()on mount,lenis_init()on unmount. It renders<span data-lenis-off hidden>, and while that is in the DOM nothing recreates Lenis: not the route init, not Fancybox on close. - A Fancybox popupNothingfancybox_init fires
lenis:destroywhen a popup opens andlenis:initwhen the last one closes (a nested popup closing leaves it off). The popup scrolls natively. - An overlay of your own (not Fancybox) with its own scroll
lenis_destroy()on open,lenis_init()on closeWhat Fancybox does. Or keep Lenis and putdata-lenis-preventon the overlay's scroll area. - Freeze the page under something
lenis_pause()/lenis_resume()lenis.stop()/start(): every wheel and touch event is cancelled, an overlay's too, except underdata-lenis-prevent. The instance stays. - Compare smooth and native while debugging a pin
<UkLenisToggle />A debug button:lenis_destroy()/lenis_init()on click. Not for a client site.
- window.lenis
- …
no attributedata-lenis-preventCode
{/* a box with its own scroll */}
<div className="max-h-80 overflow-y-auto" data-lenis-prevent="">…</div>
{/* a page with native scroll throughout */}
import UkLenisOff from '@/components/uikit/UkLenisOff'
<UkLenisOff />
{/* an overlay of your own */}
import { lenis_destroy, lenis_init } from '@/es-scripts/LENIS/uk-lenis-init'
onOpen: lenis_destroy()
onClose: lenis_init()
// or, with no import: window.dispatchEvent(new Event('lenis:destroy'))The API (es-scripts/LENIS/uk-lenis-init.js)
lenis_init()functionCreates the instance, unless one exists or[data-lenis-off]is in the DOM. Alsowindow.lenis_init()and the eventlenis:init.lenis_destroy()functionRemoves the GSAP ticker,destroy(),window.lenis = null, strips thelenis*classes from<html>: native scroll.lenis:destroy.lenis_pause()functionlenis.stop(): wheel and touch blocked, instance kept.lenis:pause.lenis_resume()functionlenis.start(), or creates one if there is none.lenis:resume.lenis_toggle()functionPause when running, resume when paused, create when missing.lenis:toggle.
| Function | Type | Default | What |
|---|---|---|---|
lenis_init() | function | - | Creates the instance, unless one exists or [data-lenis-off] is in the DOM. Also window.lenis_init() and the event lenis:init. |
lenis_destroy() | function | - | Removes the GSAP ticker, destroy(), window.lenis = null, strips the lenis* classes from <html>: native scroll. lenis:destroy. |
lenis_pause() | function | - | lenis.stop(): wheel and touch blocked, instance kept. lenis:pause. |
lenis_resume() | function | - | lenis.start(), or creates one if there is none. lenis:resume. |
lenis_toggle() | function | - | Pause when running, resume when paused, create when missing. lenis:toggle. |
Each is also on window and behind a window event of the same name (lenis:pause…). Pause, resume, toggle and destroy log to the console; window.__lenis_debug = true adds an on-screen badge.
UkLenisOff and UkLenisToggle
UkLenisOff takes no props and renders <span data-lenis-off="" hidden />. UkLenisToggle, the debug button:
labelstringdefault'Lenis'The text before the state (Lenis: ON (smooth)).stickybooleandefaultfalseFixed at the bottom corner (16px from the end and the bottom) instead of inline.
| Prop | Type | Default | What |
|---|---|---|---|
label | string | 'Lenis' | The text before the state (Lenis: ON (smooth)). |
sticky | boolean | false | Fixed at the bottom corner (16px from the end and the bottom) instead of inline. |
Known gap (17.10): the toggle reads window.lenis once, on mount, before the route init (50ms later) creates Lenis. On a page loaded directly it shows OFF while Lenis is on, and its first click only corrects the label. The window.lenis readout above is the true state.
Rules
- A scroll box in the page gets
data-lenis-prevent; a page with native scroll throughout gets<UkLenisOff />. Pause is not for a popup that scrolls: it cancels the popup's wheel too (why fancybox_init destroys Lenis instead). - Never
window.lenis.destroy()by hand:lenis_destroy()also takes the GSAP ticker off and clears the classes on<html>. - Never give an element
id="lenis": the browser exposes it aswindow.lenis,lenis_init()takes it for an instance and never starts.
Live in
- A guide lesson ·
<UkLenisOff />, the course sidebar scrolls on its own - Gym membership ·
data-lenis-preventon the table - Fancybox · the hand-off, and the sheet's own scroll
- Mask Section ·
<UkLenisToggle />
