Skip to content

Commit b21ba4b

Browse files
committed
feat: add ability to configure observer threshold
1 parent 85f3e10 commit b21ba4b

File tree

5 files changed

+176
-31
lines changed

5 files changed

+176
-31
lines changed

src/lib/actions.ts

+32-22
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import type { Action } from 'svelte/action';
2-
import type { TransitionSettings, TransitionStyling } from './types.js';
2+
import { CountedIntersectionObserver } from './observer.js';
3+
import type { TransitionSettings, TransitionStyling, TransitionThreshold } from './types.js';
34

4-
let observer: IntersectionObserver;
5+
const DEFAULT_DURATION = 500 as const;
6+
const DEFAULT_THRESHOLD = 0.25 as const;
7+
8+
const observerMap = new Map<TransitionThreshold, CountedIntersectionObserver>();
59
const transitionMap = new Map<HTMLElement, TransitionSettings>();
6-
const DEFAULT_DURATION = 500;
710

811
function onObserverChange(entries: IntersectionObserverEntry[]) {
912
entries.forEach((entry) => {
@@ -42,29 +45,17 @@ function addStyling(node: HTMLElement, styling?: TransitionStyling) {
4245
if (!styling) return;
4346

4447
if (typeof styling === 'string') {
45-
styling && node.classList.add(...styling.split(' '));
48+
node.classList.add(...styling.split(' '));
4649
} else {
4750
Object.assign(node.style, styling);
4851
}
4952
}
5053

51-
// function removeStyling(node: HTMLElement, styling?: TransitionStyling) {
52-
// if (!styling) return;
53-
54-
// if (typeof styling === 'string') {
55-
// node.classList.remove(...styling.split(' '));
56-
// } else {
57-
// Object.keys(styling).forEach((key) => {
58-
// node.style.removeProperty(key);
59-
// });
60-
// }
61-
// }
62-
63-
function getOrCreateObserver() {
54+
function getOrCreateObserver(threshold: number | number[]) {
55+
let observer = observerMap.get(threshold);
6456
if (!observer) {
65-
observer = new IntersectionObserver(onObserverChange, {
66-
threshold: 0.25
67-
});
57+
observer = new CountedIntersectionObserver(onObserverChange, { threshold });
58+
observerMap.set(threshold, observer);
6859
}
6960
return observer;
7061
}
@@ -75,14 +66,33 @@ export const appear: Action<HTMLElement, TransitionSettings> = (
7566
) => {
7667
addStyling(node, transition.from);
7768
transitionMap.set(node, transition);
78-
getOrCreateObserver().observe(node);
69+
let observer = getOrCreateObserver(transition.threshold ?? DEFAULT_THRESHOLD);
70+
observer.observe(node);
7971

8072
return {
8173
update(newTransition: TransitionSettings) {
8274
transitionMap.set(node, newTransition);
75+
76+
if (newTransition.threshold !== transition.threshold) {
77+
observer.unobserve(node);
78+
if (observer.isEmpty()) {
79+
observer.disconnect();
80+
observerMap.delete(transition.threshold ?? DEFAULT_THRESHOLD);
81+
}
82+
83+
observer = getOrCreateObserver(newTransition.threshold ?? DEFAULT_THRESHOLD);
84+
}
85+
86+
transition = { ...newTransition };
8387
},
8488
destroy() {
85-
getOrCreateObserver().unobserve(node);
89+
observer.unobserve(node);
90+
91+
if (observer.isEmpty()) {
92+
observer.disconnect();
93+
observerMap.delete(transition.threshold ?? DEFAULT_THRESHOLD);
94+
}
95+
8696
transitionMap.delete(node);
8797
}
8898
};

src/lib/observer.ts

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
export class CountedIntersectionObserver extends IntersectionObserver {
2+
private observedElements: Set<Element>;
3+
get count() {
4+
return this.observedElements.size;
5+
}
6+
7+
constructor(
8+
callback: IntersectionObserverCallback,
9+
options?: IntersectionObserverInit | undefined
10+
) {
11+
super(callback, options);
12+
this.observedElements = new Set();
13+
}
14+
15+
public observe(target: Element) {
16+
super.observe(target);
17+
this.observedElements.add(target);
18+
}
19+
20+
public unobserve(target: Element) {
21+
super.unobserve(target);
22+
this.observedElements.delete(target);
23+
}
24+
25+
public disconnect() {
26+
super.disconnect();
27+
this.observedElements.clear();
28+
}
29+
30+
public isEmpty() {
31+
return this.count === 0;
32+
}
33+
}

src/lib/types.ts

+9
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
export type TransitionStyling = string | Partial<CSSStyleDeclaration>;
22

3+
export type TransitionThreshold = number | number[];
4+
35
export type TransitionSettings = {
46
/**
57
* A string of class names or a styling object to apply when the element is in view.
@@ -10,6 +12,13 @@ export type TransitionSettings = {
1012
* @default 500
1113
*/
1214
duration?: number;
15+
/**
16+
* Threshold at which the transition will be triggered.
17+
* Either a single number or an array of numbers between 0.0 and 1.0.
18+
* @default 0.25
19+
* @see https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver/IntersectionObserver#threshold
20+
*/
21+
threshold?: TransitionThreshold;
1322
} & (
1423
| {
1524
/**

src/routes/+layout.ts

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const ssr = false;

src/routes/+page.svelte

+101-9
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,113 @@
11
<script>
2-
import { appear } from '../lib/actions.js';
2+
// @ts-ignore
3+
import { appear } from '$lib/actions';
34
</script>
45

56
<h1
67
use:appear={{
7-
from: {
8-
opacity: '0',
9-
transform: 'translateY(40px)',
10-
transitionTimingFunction: 'ease-out'
11-
},
128
to: {
139
opacity: '1',
1410
transform: 'translateY(0)'
15-
},
16-
duration: 500,
17-
bothWays: true
11+
}
1812
}}
1913
>
2014
Svelte Appear Transition
2115
</h1>
16+
<div class="wrapper">
17+
<div class="two-column">
18+
<div
19+
class="box"
20+
use:appear={{
21+
from: {
22+
opacity: '0',
23+
transform: 'translateX(-40px)',
24+
transitionTimingFunction: 'ease-out'
25+
},
26+
to: {
27+
opacity: '1',
28+
transform: 'translateX(0)'
29+
},
30+
duration: 800,
31+
threshold: 0.75
32+
}}
33+
></div>
34+
<div
35+
class="box"
36+
use:appear={{
37+
from: {
38+
opacity: '0',
39+
transform: 'translateX(40px)',
40+
transitionTimingFunction: 'ease-out'
41+
},
42+
to: {
43+
opacity: '1',
44+
transform: 'translateX(0)'
45+
},
46+
duration: 800,
47+
threshold: 0.75
48+
}}
49+
></div>
50+
</div>
51+
<div
52+
class="box wide"
53+
use:appear={{
54+
from: {
55+
opacity: '0',
56+
transform: 'translateY(-40px)',
57+
transitionTimingFunction: 'ease-out'
58+
},
59+
to: {
60+
opacity: '1',
61+
transform: 'translateY(0)'
62+
},
63+
duration: 500,
64+
threshold: 0.5
65+
}}
66+
></div>
67+
</div>
68+
69+
<style>
70+
h1 {
71+
font-family: ui-rounded, 'Hiragino Maru Gothic ProN', Quicksand, Comfortaa, Manjari,
72+
'Arial Rounded MT', 'Arial Rounded MT Bold', Calibri, source-sans-pro, sans-serif;
73+
margin: 2rem 2rem 0 2rem;
74+
75+
/* Styling that will be overwritten by the transition */
76+
opacity: 0;
77+
transform: translateY(40px);
78+
transition:
79+
opacity 0.5s ease-out,
80+
transform 0.5s ease-out;
81+
}
82+
83+
.wrapper {
84+
display: flex;
85+
flex-direction: column;
86+
justify-content: center;
87+
align-items: center;
88+
flex-wrap: wrap;
89+
gap: 2rem;
90+
padding: 2rem;
91+
}
92+
93+
.two-column {
94+
display: flex;
95+
justify-content: center;
96+
align-items: center;
97+
flex-wrap: wrap;
98+
gap: 2rem;
99+
width: 100%;
100+
}
101+
102+
.box {
103+
border-radius: 1rem;
104+
background-color: #ff3e00;
105+
height: 80vh;
106+
min-height: 500px;
107+
flex-grow: 1;
108+
}
109+
110+
.wide {
111+
width: 100%;
112+
}
113+
</style>

0 commit comments

Comments
 (0)