Skip to content

Commit 0b60e5d

Browse files
committed
js/css: Animate comment insertion and deletion (fade-in/out, smooth scroll)
Add a lightweight animations module to animate comment insertion, reveal and removal using CSS keyframes plus a small JS helper for smooth scrolling and reliable animation end detection. Integrates into the comment insert/remove flow and respects prefers-reduced-motion. Animations are opt-in via the animations config option (default: false) and fall back to instant behaviour when disabled or unsupported. Closes #6
1 parent 194a18d commit 0b60e5d

7 files changed

Lines changed: 721 additions & 8 deletions

File tree

CHANGES.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
11
Changelog for Isso
22
==================
33

4+
%(version)s (%(date)s)
5+
--------------------
6+
7+
New Features
8+
^^^^^^^^^^^^
9+
10+
- Animate comment insertion and deletion (fade-in/out, smooth scroll) (`#1105`_, pkvach)
11+
12+
.. _#1105: https://github.com/isso-comments/isso/pull/1105
13+
414
0.14.0 (2026-03-26)
515
--------------------
616

docs/docs/reference/client-config.rst

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,21 @@ data-isso-sorting
272272

273273
.. versionadded:: 0.13.1
274274

275+
.. _data-isso-animations:
276+
277+
data-isso-animations
278+
Enable or disable animations for comment insertion and deletion.
279+
When enabled, new comments will fade in and deleted comments will fade out.
280+
Animations automatically respect the user's ``prefers-reduced-motion`` setting.
281+
282+
.. code-block:: html
283+
284+
<script src="..." data-isso-animations="true"></script>
285+
286+
Default: ``false``
287+
288+
.. versionadded:: 0.14.1
289+
275290
Deprecated Client Settings
276291
--------------------------
277292

isso/css/isso.css

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@
3434
--isso-comment-divider-color: rgba(0, 0, 0, 0.1);
3535
--isso-page-author-suffix-color: #2c2c2c;
3636
--isso-target-fade-background-color: #eee5a1;
37+
38+
/* Animation variables */
39+
--isso-animation-duration: 0.3s;
40+
--isso-animation-timing: ease-out;
41+
--isso-comment-max-height: 1000px;
3742
}
3843

3944
/* ========================================================================== */
@@ -332,6 +337,56 @@ h4.isso-thread-heading {
332337
/* Animations */
333338
/* ========================================================================== */
334339

340+
.isso-comment.isso-anim-initial {
341+
opacity: 0;
342+
transform: translateY(-10px);
343+
}
344+
345+
/* Animation for new comments appearing */
346+
@keyframes isso-fade-in {
347+
from {
348+
opacity: 0;
349+
transform: translateY(-10px);
350+
}
351+
to {
352+
opacity: 1;
353+
transform: translateY(0);
354+
}
355+
}
356+
357+
/* Animation for comments being removed */
358+
@keyframes isso-fade-out {
359+
from {
360+
opacity: 1;
361+
transform: translateY(0);
362+
max-height: var(--isso-comment-max-height);
363+
}
364+
to {
365+
opacity: 0;
366+
transform: translateY(-10px);
367+
max-height: 0;
368+
}
369+
}
370+
371+
/* Apply fade-in animation to new comments */
372+
.isso-comment.isso-anim-in {
373+
animation: isso-fade-in var(--isso-animation-duration) var(--isso-animation-timing);
374+
}
375+
376+
/* Apply fade-out animation to removed comments */
377+
.isso-comment.isso-anim-out {
378+
animation: isso-fade-out var(--isso-animation-duration) var(--isso-animation-timing);
379+
overflow: hidden;
380+
}
381+
382+
/* Respect user's motion preferences */
383+
@media (prefers-reduced-motion: reduce) {
384+
.isso-comment.isso-anim-in,
385+
.isso-comment.isso-anim-out {
386+
animation: none;
387+
}
388+
}
389+
335390
/* "target" means the comment that's being linked to, for example:
336391
* https://example.com/blog/example/#isso-15
337392
*/

isso/js/app/animations.js

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
"use strict";
2+
3+
var config = require("app/config");
4+
5+
// Animation timing constants
6+
7+
// Default timeout for animation completion fallback in milliseconds. This is used when:
8+
// 1. The computed animation duration cannot be read from the element
9+
// 2. The animation duration is 0s or invalid
10+
var DEFAULT_ANIMATION_TIMEOUT_MS = 500;
11+
12+
// Additional buffer time added to the computed animation duration to ensure
13+
// the animation completes before the callback is triggered
14+
var ANIMATION_BUFFER_MS = 200;
15+
16+
// Interval for checking scroll position stability
17+
var SCROLL_CHECK_INTERVAL_MS = 50;
18+
19+
// Maximum time to wait for scroll completion before forcing callback
20+
var SCROLL_COMPLETION_TIMEOUT_MS = 2000;
21+
22+
/**
23+
* Unwrap a DOM element if it's wrapped in our Element class
24+
* @param {Element|HTMLElement} element - Either a wrapped Element or raw DOM element
25+
* @returns {HTMLElement} - Raw DOM element
26+
*/
27+
var unwrap = function(element) {
28+
// Check if it's our wrapped Element class (has .obj property)
29+
if (element && typeof element === 'object' && element.obj instanceof window.Element) {
30+
return element.obj;
31+
}
32+
// Already a raw DOM element or null
33+
return element;
34+
};
35+
36+
/**
37+
* Check if animations are enabled based on config and user preferences
38+
* @returns {boolean}
39+
*/
40+
var isEnabled = function() {
41+
// Check if animations are disabled in config
42+
if (config["animations"] === false) {
43+
return false;
44+
}
45+
46+
// Check if user prefers reduced motion
47+
if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
48+
return false;
49+
}
50+
51+
return true;
52+
};
53+
54+
/**
55+
* Animate element insertion with fade-in effect
56+
* @param {Element|HTMLElement} element - The DOM element to animate (wrapped or raw)
57+
* @param {boolean} scrollIntoView - Whether to scroll to the element
58+
*/
59+
var animateInsert = function(element, scrollIntoView) {
60+
var rawElement = unwrap(element);
61+
62+
if (!isEnabled()) {
63+
if (scrollIntoView) {
64+
scrollToElement(rawElement);
65+
}
66+
return;
67+
}
68+
69+
// Function to add animation class
70+
var addAnimation = function() {
71+
// Remove initial state and add animation class
72+
rawElement.classList.remove('isso-anim-initial');
73+
rawElement.classList.add('isso-anim-in');
74+
75+
// Remove animation class after animation completes
76+
var handleAnimationEnd = function() {
77+
rawElement.classList.remove('isso-anim-in');
78+
rawElement.removeEventListener('animationend', handleAnimationEnd);
79+
};
80+
81+
rawElement.addEventListener('animationend', handleAnimationEnd);
82+
};
83+
84+
requestAnimationFrame(function() {
85+
if (scrollIntoView) {
86+
// Wait for scroll to complete before animating
87+
scrollToElement(rawElement, function() {
88+
addAnimation();
89+
});
90+
} else {
91+
addAnimation();
92+
}
93+
});
94+
};
95+
96+
/**
97+
* Prepare and insert element with animation
98+
* This is a convenience function that handles the complete animation workflow:
99+
* 1. Prepares the element for animation (adds initial state)
100+
* 2. Appends element to parent
101+
* 3. Triggers the animation
102+
*
103+
* @param {Element|HTMLElement} element - The DOM element to animate (wrapped or raw)
104+
* @param {Element|HTMLElement} parent - The parent element to append to (wrapped or raw)
105+
* @param {boolean} scrollIntoView - Whether to scroll to the element
106+
*/
107+
var insertWithAnimation = function(element, parent, scrollIntoView) {
108+
var rawElement = unwrap(element);
109+
var rawParent = unwrap(parent);
110+
111+
if (isEnabled()) {
112+
// Set initial state before element is visible
113+
rawElement.classList.add('isso-anim-initial');
114+
}
115+
116+
// Append to parent
117+
rawParent.appendChild(rawElement);
118+
119+
// Trigger animation
120+
animateInsert(rawElement, scrollIntoView);
121+
};
122+
123+
/**
124+
* Animate element removal with fade-out effect
125+
* @param {Element|HTMLElement} element - The DOM element to animate (wrapped or raw)
126+
* @param {Function} callback - Function to call after animation completes
127+
*/
128+
var animateRemove = function(element, callback) {
129+
var rawElement = unwrap(element);
130+
131+
if (!isEnabled()) {
132+
if (callback) {
133+
callback();
134+
}
135+
return;
136+
}
137+
138+
// Add animation class
139+
rawElement.classList.add('isso-anim-out');
140+
141+
var completed = false;
142+
var timeoutId = null;
143+
144+
// Wait for animation to complete before removing
145+
var handleAnimationEnd = function() {
146+
if (completed) return;
147+
completed = true;
148+
149+
// Clear the fallback timeout
150+
if (timeoutId !== null) {
151+
clearTimeout(timeoutId);
152+
}
153+
154+
rawElement.removeEventListener('animationend', handleAnimationEnd);
155+
if (callback) {
156+
callback();
157+
}
158+
};
159+
160+
rawElement.addEventListener('animationend', handleAnimationEnd);
161+
162+
// Get animation duration from computed style for fallback timeout
163+
var fallbackTimeout = DEFAULT_ANIMATION_TIMEOUT_MS;
164+
try {
165+
var style = window.getComputedStyle(rawElement);
166+
var durationStr = style.animationDuration;
167+
if (durationStr && durationStr !== '0s') {
168+
// Parse duration (could be in 's' or 'ms')
169+
var duration = parseFloat(durationStr);
170+
// Validate that duration is a valid number
171+
if (!isNaN(duration) && duration > 0) {
172+
fallbackTimeout = durationStr.includes('ms') ? duration : duration * 1000;
173+
// Add buffer to ensure animation completes
174+
fallbackTimeout += ANIMATION_BUFFER_MS;
175+
}
176+
}
177+
} catch (e) {
178+
// If we can't read the style, use default fallback timeout
179+
}
180+
181+
// Fallback timeout in case animationend doesn't fire
182+
timeoutId = setTimeout(function() {
183+
handleAnimationEnd();
184+
}, fallbackTimeout);
185+
};
186+
187+
/**
188+
* Creates a self-cancelling scroll completion watcher.
189+
* Polls window.scrollY every 50ms and fires the callback once
190+
* the position has been stable for one tick, or after a 2s safety timeout.
191+
*
192+
* @param {Function} callback - Called once when scrolling is deemed complete
193+
* @returns {void}
194+
*/
195+
var watchScrollCompletion = function(callback) {
196+
var called = false;
197+
var lastScrollY = window.scrollY;
198+
var scrollCheckInterval = null;
199+
var fallbackTimeout = null;
200+
201+
// Single exit point — clears both the interval and timeout
202+
// before invoking the callback, preventing double invocation
203+
var done = function() {
204+
if (called) return;
205+
called = true;
206+
if (scrollCheckInterval !== null) {
207+
clearInterval(scrollCheckInterval);
208+
}
209+
if (fallbackTimeout !== null) {
210+
clearTimeout(fallbackTimeout);
211+
}
212+
callback();
213+
};
214+
215+
// Poll at regular intervals — the initial delay naturally ensures the first
216+
// check happens after scrollIntoView has had a chance to begin moving
217+
scrollCheckInterval = setInterval(function() {
218+
if (window.scrollY === lastScrollY) {
219+
done();
220+
}
221+
lastScrollY = window.scrollY;
222+
}, SCROLL_CHECK_INTERVAL_MS);
223+
224+
// Safety net: if scroll detection stalls (e.g. very long page,
225+
// slow device, or reduced-motion override), force-complete after timeout
226+
fallbackTimeout = setTimeout(done, SCROLL_COMPLETION_TIMEOUT_MS);
227+
};
228+
229+
/**
230+
* Smooth scrolls to a given element with an optional post-scroll callback.
231+
* Falls back to instant scroll on browsers that don't support smooth behavior.
232+
*
233+
* @param {Element|HTMLElement} element - The target DOM element to scroll to (wrapped or raw)
234+
* @param {Function} callback - Optional. Called after scroll completes (or immediately on fallback)
235+
*/
236+
var scrollToElement = function(element, callback) {
237+
var rawElement = unwrap(element);
238+
239+
if (!rawElement) {
240+
return;
241+
}
242+
243+
// Smooth scroll path — supported in all modern browsers
244+
if ('scrollBehavior' in document.documentElement.style) {
245+
try {
246+
rawElement.scrollIntoView({ behavior: 'smooth' });
247+
248+
// Only watch for completion if a callback was provided
249+
if (callback) {
250+
watchScrollCompletion(callback);
251+
}
252+
return;
253+
} catch (e) {
254+
// scrollIntoView with options not supported — fall through to basic scroll
255+
}
256+
}
257+
258+
// Fallback: instant scroll, no completion detection needed
259+
rawElement.scrollIntoView();
260+
261+
// Instant scroll, so callback can be called immediately
262+
if (callback) {
263+
requestAnimationFrame(callback);
264+
}
265+
};
266+
267+
module.exports = {
268+
isEnabled: isEnabled,
269+
insertWithAnimation: insertWithAnimation,
270+
animateRemove: animateRemove,
271+
scrollToElement: scrollToElement
272+
};

isso/js/app/default_config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ var default_config = {
2323
"vote-levels": null,
2424
"feed": false,
2525
"page-author-hashes": "",
26+
"animations": false,
2627
};
2728
Object.freeze(default_config);
2829

0 commit comments

Comments
 (0)