Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ New Features
^^^^^^^^^^^^

- Add option to show/hide website field in comment form (`#1111`_, pkvach)
- Animate comment insertion and deletion (fade-in/out, smooth scroll) (`#1105`_, pkvach)

.. _#1111: https://github.com/isso-comments/isso/pull/1111
.. _#1105: https://github.com/isso-comments/isso/pull/1105

0.14.0 (2026-03-26)
--------------------
Expand Down
15 changes: 15 additions & 0 deletions docs/docs/reference/client-config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,21 @@ data-isso-sorting

.. versionadded:: 0.13.1

.. _data-isso-animations:

data-isso-animations
Enable or disable animations for comment insertion and deletion.
When enabled, new comments will fade in and deleted comments will fade out.
Animations automatically respect the user's ``prefers-reduced-motion`` setting.

.. code-block:: html

<script src="..." data-isso-animations="true"></script>

Default: ``false``

.. versionadded:: 0.14.1

Deprecated Client Settings
--------------------------

Expand Down
55 changes: 55 additions & 0 deletions isso/css/isso.css
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@
--isso-comment-divider-color: rgba(0, 0, 0, 0.1);
--isso-page-author-suffix-color: #2c2c2c;
--isso-target-fade-background-color: #eee5a1;

/* Animation variables */
--isso-animation-duration: 0.3s;
--isso-animation-timing: ease-out;
--isso-comment-max-height: 1000px;
}

/* ========================================================================== */
Expand Down Expand Up @@ -332,6 +337,56 @@ h4.isso-thread-heading {
/* Animations */
/* ========================================================================== */

.isso-comment.isso-anim-initial {
opacity: 0;
transform: translateY(-10px);
}

/* Animation for new comments appearing */
@keyframes isso-fade-in {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

/* Animation for comments being removed */
@keyframes isso-fade-out {
from {
opacity: 1;
transform: translateY(0);
max-height: var(--isso-comment-max-height);
}
to {
opacity: 0;
transform: translateY(-10px);
max-height: 0;
}
}

/* Apply fade-in animation to new comments */
.isso-comment.isso-anim-in {
animation: isso-fade-in var(--isso-animation-duration) var(--isso-animation-timing);
}

/* Apply fade-out animation to removed comments */
.isso-comment.isso-anim-out {
animation: isso-fade-out var(--isso-animation-duration) var(--isso-animation-timing);
overflow: hidden;
}

/* Respect user's motion preferences */
@media (prefers-reduced-motion: reduce) {
.isso-comment.isso-anim-in,
.isso-comment.isso-anim-out {
animation: none;
}
}

/* "target" means the comment that's being linked to, for example:
* https://example.com/blog/example/#isso-15
*/
Expand Down
272 changes: 272 additions & 0 deletions isso/js/app/animations.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
"use strict";

var config = require("app/config");

// Animation timing constants

// Default timeout for animation completion fallback in milliseconds. This is used when:
// 1. The computed animation duration cannot be read from the element
// 2. The animation duration is 0s or invalid
var DEFAULT_ANIMATION_TIMEOUT_MS = 500;

// Additional buffer time added to the computed animation duration to ensure
// the animation completes before the callback is triggered
var ANIMATION_BUFFER_MS = 200;

// Interval for checking scroll position stability
var SCROLL_CHECK_INTERVAL_MS = 50;

// Maximum time to wait for scroll completion before forcing callback
var SCROLL_COMPLETION_TIMEOUT_MS = 2000;

/**
* Unwrap a DOM element if it's wrapped in our Element class
* @param {Element|HTMLElement} element - Either a wrapped Element or raw DOM element
* @returns {HTMLElement} - Raw DOM element
*/
var unwrap = function(element) {
// Check if it's our wrapped Element class (has .obj property)
if (element && typeof element === 'object' && element.obj instanceof window.Element) {
return element.obj;
}
// Already a raw DOM element or null
return element;
};

/**
* Check if animations are enabled based on config and user preferences
* @returns {boolean}
*/
var isEnabled = function() {
// Check if animations are disabled in config
if (config["animations"] === false) {
return false;
}

// Check if user prefers reduced motion
if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
return false;
}

return true;
};

/**
* Animate element insertion with fade-in effect
* @param {Element|HTMLElement} element - The DOM element to animate (wrapped or raw)
* @param {boolean} scrollIntoView - Whether to scroll to the element
*/
var animateInsert = function(element, scrollIntoView) {
var rawElement = unwrap(element);

if (!isEnabled()) {
if (scrollIntoView) {
scrollToElement(rawElement);
}
return;
}

// Function to add animation class
var addAnimation = function() {
// Remove initial state and add animation class
rawElement.classList.remove('isso-anim-initial');
rawElement.classList.add('isso-anim-in');

// Remove animation class after animation completes
var handleAnimationEnd = function() {
rawElement.classList.remove('isso-anim-in');
rawElement.removeEventListener('animationend', handleAnimationEnd);
};

rawElement.addEventListener('animationend', handleAnimationEnd);
};

requestAnimationFrame(function() {
if (scrollIntoView) {
// Wait for scroll to complete before animating
scrollToElement(rawElement, function() {
addAnimation();
});
} else {
addAnimation();
}
});
};

/**
* Prepare and insert element with animation
* This is a convenience function that handles the complete animation workflow:
* 1. Prepares the element for animation (adds initial state)
* 2. Appends element to parent
* 3. Triggers the animation
*
* @param {Element|HTMLElement} element - The DOM element to animate (wrapped or raw)
* @param {Element|HTMLElement} parent - The parent element to append to (wrapped or raw)
* @param {boolean} scrollIntoView - Whether to scroll to the element
*/
var insertWithAnimation = function(element, parent, scrollIntoView) {
var rawElement = unwrap(element);
var rawParent = unwrap(parent);

if (isEnabled()) {
// Set initial state before element is visible
rawElement.classList.add('isso-anim-initial');
}

// Append to parent
rawParent.appendChild(rawElement);

// Trigger animation
animateInsert(rawElement, scrollIntoView);
};

/**
* Animate element removal with fade-out effect
* @param {Element|HTMLElement} element - The DOM element to animate (wrapped or raw)
* @param {Function} callback - Function to call after animation completes
*/
var animateRemove = function(element, callback) {
var rawElement = unwrap(element);

if (!isEnabled()) {
if (callback) {
callback();
}
return;
}

// Add animation class
rawElement.classList.add('isso-anim-out');

var completed = false;
var timeoutId = null;

// Wait for animation to complete before removing
var handleAnimationEnd = function() {
if (completed) return;
completed = true;

// Clear the fallback timeout
if (timeoutId !== null) {
clearTimeout(timeoutId);
}

rawElement.removeEventListener('animationend', handleAnimationEnd);
if (callback) {
callback();
}
};

rawElement.addEventListener('animationend', handleAnimationEnd);

// Get animation duration from computed style for fallback timeout
var fallbackTimeout = DEFAULT_ANIMATION_TIMEOUT_MS;
try {
var style = window.getComputedStyle(rawElement);
var durationStr = style.animationDuration;
if (durationStr && durationStr !== '0s') {
// Parse duration (could be in 's' or 'ms')
var duration = parseFloat(durationStr);
// Validate that duration is a valid number
if (!isNaN(duration) && duration > 0) {
fallbackTimeout = durationStr.includes('ms') ? duration : duration * 1000;
// Add buffer to ensure animation completes
fallbackTimeout += ANIMATION_BUFFER_MS;
}
}
} catch (e) {
// If we can't read the style, use default fallback timeout
}

// Fallback timeout in case animationend doesn't fire
timeoutId = setTimeout(function() {
handleAnimationEnd();
}, fallbackTimeout);
};

/**
* Creates a self-cancelling scroll completion watcher.
* Polls window.scrollY every 50ms and fires the callback once
* the position has been stable for one tick, or after a 2s safety timeout.
*
* @param {Function} callback - Called once when scrolling is deemed complete
* @returns {void}
*/
var watchScrollCompletion = function(callback) {
var called = false;
var lastScrollY = window.scrollY;
var scrollCheckInterval = null;
var fallbackTimeout = null;

// Single exit point — clears both the interval and timeout
// before invoking the callback, preventing double invocation
var done = function() {
if (called) return;
called = true;
if (scrollCheckInterval !== null) {
clearInterval(scrollCheckInterval);
}
if (fallbackTimeout !== null) {
clearTimeout(fallbackTimeout);
}
callback();
};

// Poll at regular intervals — the initial delay naturally ensures the first
// check happens after scrollIntoView has had a chance to begin moving
scrollCheckInterval = setInterval(function() {
if (window.scrollY === lastScrollY) {
done();
}
lastScrollY = window.scrollY;
}, SCROLL_CHECK_INTERVAL_MS);

// Safety net: if scroll detection stalls (e.g. very long page,
// slow device, or reduced-motion override), force-complete after timeout
fallbackTimeout = setTimeout(done, SCROLL_COMPLETION_TIMEOUT_MS);
};

/**
* Smooth scrolls to a given element with an optional post-scroll callback.
* Falls back to instant scroll on browsers that don't support smooth behavior.
*
* @param {Element|HTMLElement} element - The target DOM element to scroll to (wrapped or raw)
* @param {Function} callback - Optional. Called after scroll completes (or immediately on fallback)
*/
var scrollToElement = function(element, callback) {
var rawElement = unwrap(element);

if (!rawElement) {
return;
}

// Smooth scroll path — supported in all modern browsers
if ('scrollBehavior' in document.documentElement.style) {
try {
rawElement.scrollIntoView({ behavior: 'smooth' });

// Only watch for completion if a callback was provided
if (callback) {
watchScrollCompletion(callback);
}
return;
} catch (e) {
// scrollIntoView with options not supported — fall through to basic scroll
}
}

// Fallback: instant scroll, no completion detection needed
rawElement.scrollIntoView();

// Instant scroll, so callback can be called immediately
if (callback) {
requestAnimationFrame(callback);
}
};

module.exports = {
isEnabled: isEnabled,
insertWithAnimation: insertWithAnimation,
animateRemove: animateRemove,
scrollToElement: scrollToElement
};
1 change: 1 addition & 0 deletions isso/js/app/default_config.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ var default_config = {
"vote-levels": null,
"feed": false,
"page-author-hashes": "",
"animations": false,
};
Object.freeze(default_config);

Expand Down
Loading
Loading