Skip to content

Instantly share code, notes, and snippets.

@sannajammeh
Last active July 11, 2022 02:39
Show Gist options
  • Save sannajammeh/db015a44aa0b83cb423d9f28ee601609 to your computer and use it in GitHub Desktop.
Save sannajammeh/db015a44aa0b83cb423d9f28ee601609 to your computer and use it in GitHub Desktop.
Prototype of container-less position:sticky event emitter

Motivations

  • Google's example is too complex
  • Observing the exact point an element will start sticking allows for greater control
  • Window scroll event fires too many times to count.
  • Container-less (supports any element position)

Future revisions

I intend to make this class more modular, abstracting into helper functions and allowing for passing in elements. React hook version is also intended (using the future modular build). Also a more performant observation technique.

Roadmap

  • Use Houdini CSSOM for sentinel
  • Modular
  • React hooks
  • Multi element / selector support

Syntax goal (pseudo code)

  // Vanilla TS
  const emitter = createStickyEmitter(...elements | selector | element);
  emitter.listen();
  () => emitter.dispose();
  
  const stickyElement = document.querySelector("header");
  
  header.addEventListener("sticky-change", () => {
    // handle sticky event
  })
  
  // React TS
  const [sticky, ref] = useStickyEvent(); // Uses vanilla TS under the hood
  () => {
    return <header style={{borderBottom: sticky ? "1px solid blue" : undefined}} ref={ref} />
  }
export class StickyEvents {
items: HTMLElement[] = [];
subscriptions: (() => void)[] = [];
selector: string;
constructor(selector: string) {
this.selector = selector;
}
public init = () => {
this.items = Array.from(document.querySelectorAll(this.selector));
this.items.forEach((item) => {
// Add sentinel to parent element
const sentinel = document.createElement('div');
sentinel.className = 'sticky-sentinel';
sentinel.setAttribute('aria-hidden', 'true');
sentinel.style.visibility = 'hidden';
sentinel.style.height = '0';
sentinel.style.width = '0';
item.insertAdjacentElement('beforebegin', sentinel); // Insert before begin
// Expect to throw if top is not present.
const top = getComputedStyle(item)
.getPropertyValue('top')
.match(/\d+/g)![0];
// Observe sentinel position, compare with item top position and update sticky state
const observer = new IntersectionObserver(
(entries) => {
const { isIntersecting, ...rest } = entries[0];
if (isIntersecting) {
this.emit(true, item);
} else {
this.emit(false, item);
}
},
{
threshold: [0],
rootMargin: `0px 0px -${window.innerHeight - Number(top)}px 0px`, // Evil negative rootMargin hack
}
);
observer.observe(sentinel);
this.subscriptions.push(() => {
observer.disconnect();
sentinel.remove();
});
});
};
public destroy = () => {
this.subscriptions.forEach((subscription) => subscription());
};
private emit(sticky: boolean, target: HTMLElement) {
const e = new CustomEvent('sticky-change', {
detail: { sticky, stuck: sticky, target },
});
document.dispatchEvent(e);
}
}
export type StickyEvent = CustomEvent<{
sticky: boolean;
stuck: boolean;
target: HTMLElement;
}>;
const stickyEvents = new StickyEvents({
selector: '.sticky-item',
});
stickyEvents.init();
const stickyElement = document.querySelector("header");
header.addEventListener("sticky-change", () => {
// handle sticky event
})
() => stickyEvents.destroy();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment