Oficinas
Sierra Gorda 36, Lomas de Chapultepec,
Miguel Hidalgo, 11000,
Ciudad de México, CDMX

En Octano Payments transformamos la manera en que cobras. Somos un equipo de especialistas en medios de pago, con experiencia multidisciplinaria y una sola misión: llevar tu comercio al siguiente nivel.
Nos enfocamos en que escales tus ventas, simplifiques tu operación y le brindes a tus clientes una experiencia de pago ágil, segura y sin fricciones. Te acompañamos en todo el proceso, con una plataforma sólida, soporte cercano y resultados medibles desde el primer día.



Integra pagos con tarjeta en tu sitio web fácilmente
Cobros rápidos, seguros y sin complicaciones
Experiencia de usuario optimizada para aumentar la conversión
Soporte humano, real y siempre disponible
Escalabilidad para que crezcas sin límites
Todo lo que necesitas para procesar pagos en línea de forma segura y eficiente.
Acepta tarjetas de crédito y débito de las principales marcas: Visa, Mastercard.
Autorización instantánea
3D Secure
Tokenización
Protección de datos con encriptación de extremo a extremo y prevención de fraude.
Risk scoring
PCI compliance
Herramientas de onboarding
Herramientas de control de contracargos
Integración sencilla con APIs RESTful, SDKs y webhooks para todos los lenguajes.
RESTful APIs
SDKs nativos
Webhooks
Dashboard en tiempo real con métricas de negocio y reportes detallados de transacciones.
Dashboard real-time
Reportes custom
Compliance local
Con nuestra Solución Llave en Mano, llevamos tu comercio al entorno digital de forma rápida, segura y completamente gestionada. Sabemos que no todos los negocios tienen el tiempo o los recursos para desarrollar su propio sitio web o integrar medios de pago, por eso nosotros lo hacemos por ti.
Nos encargamos de principio a fin: desde el diseño de la estrategia digital hasta la implementación tecnológica, asegurando que puedas empezar a vender en línea sin preocuparte por la infraestructura, la seguridad o los procesos regulatorios.

Consultoría personalizada
Analizamos tu negocio, tu mercado y tus necesidades para definir la mejor estrategia digital y de pagos.
Diseño y desarrollo del sitio web
Creamos una página moderna, responsiva y optimizada para convertir visitantes en clientes, adaptada a tu identidad de marca.
Integración de pagos segura y flexible
Incorporamos nuestra pasarela de pagos con soporte para tarjetas, cumpliendo con los más altos estándares de seguridad PCI DSS.
Configuración de órdenes y liquidaciones
Tus cobros se procesan y liquidan automáticamente, con reportes claros y en tiempo real.
Soporte técnico y operativo
Te acompañamos en cada etapa para garantizar una operación continua y sin fricciones.
Entregarte una solución completa, escalable y personalizada que acelere el lanzamiento de tu negocio digital, aumente tus ventas y te permita concentrarte en lo que realmente importa: hacer crecer tu empresa. Desde el primer día, tendrás una plataforma lista para operar, con tecnología probada y el respaldo de expertos en medios de pago.
(function() {
const config = {
offset: 40,
padding: 50,
anchor: 'top',
disabled: false,
threshold: 768,
scale: 0.92,
scaleIncrement: 0.04,
shadow: true,
darken: true,
rotate: true,
perspective: 1000,
animationDuration: 2,
staggerOffset: 18,
mobileScale: 0.6,
disableMobile: false
};
const state = {
lastScrollY: 0,
scrollDirection: 'down'
};
function initScrollStack() {
const containers = document.querySelectorAll('[data-scrollstack-container]');
if (!containers.length) return;
containers.forEach(container => {
const cards = Array.from(container.querySelectorAll('[data-scrollstack-card]'));
if (!cards.length) return;
const containerConfig = {
offset: parseFloat(container.getAttribute('data-scrollstack-offset')) || config.offset,
padding: parseFloat(container.getAttribute('data-scrollstack-padding')) || config.padding,
anchor: container.getAttribute('data-scrollstack-anchor') || config.anchor,
disabled: container.getAttribute('data-scrollstack-disabled') === 'true' || config.disabled,
threshold: parseInt(container.getAttribute('data-scrollstack-threshold')) || config.threshold,
scale: parseFloat(container.getAttribute('data-scrollstack-scale')) || config.scale,
scaleIncrement: parseFloat(container.getAttribute('data-scrollstack-scale-increment')) || config.scaleIncrement,
shadow: container.getAttribute('data-scrollstack-shadow') !== 'false' && config.shadow,
darken: container.getAttribute('data-scrollstack-darken') !== 'false' && config.darken,
rotate: container.getAttribute('data-scrollstack-rotate') !== 'false' && config.rotate,
perspective: parseInt(container.getAttribute('data-scrollstack-perspective')) || config.perspective,
animationDuration: parseFloat(container.getAttribute('data-scrollstack-animation-duration')) || config.animationDuration,
staggerOffset: parseFloat(container.getAttribute('data-scrollstack-stagger-offset')) || config.staggerOffset,
mobileScale: parseFloat(container.getAttribute('data-scrollstack-mobile-scale')) || config.mobileScale,
disableMobile: container.getAttribute('data-scrollstack-disable-mobile') === 'true' || config.disableMobile
};
if (containerConfig.rotate) {
container.style.perspective = `${containerConfig.perspective}px`;
}
setupCards(container, cards, containerConfig);
handleScrollEvent(container, cards, containerConfig);
window.addEventListener('scroll', () => {
const currentScrollY = window.scrollY;
state.scrollDirection = currentScrollY > state.lastScrollY ? 'down' : 'up';
state.lastScrollY = currentScrollY;
handleScrollEvent(container, cards, containerConfig);
});
let resizeTimeout;
window.addEventListener('resize', () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
setupCards(container, cards, containerConfig);
handleScrollEvent(container, cards, containerConfig);
}, 200);
});
});
}
function setupCards(container, cards, config) {
const isMobile = window.innerWidth < config.threshold;
if ((config.disabled) || (config.disableMobile && isMobile)) {
cards.forEach(card => {
card.style.position = '';
card.style.top = '';
card.style.zIndex = '';
card.style.transform = '';
card.style.boxShadow = '';
card.style.transition = '';
if (card._darkenOverlay) {
card.removeChild(card._darkenOverlay);
delete card._darkenOverlay;
}
});
return;
}
container.dataset.containerHeight = container.offsetHeight;
cards.forEach((card, index) => {
const targetScale = Math.max(0.7, 1 - (index * config.scaleIncrement));
card.dataset.targetScale = targetScale;
if (!card.dataset.originalHeight) {
card.dataset.originalHeight = card.offsetHeight;
card.dataset.originalWidth = card.offsetWidth;
}
card.style.position = 'relative';
card.style.zIndex = cards.length - index;
card.style.transition = `transform ${config.animationDuration}s ease, box-shadow ${config.animationDuration}s ease`;
card.style.willChange = 'transform, box-shadow';
card.style.transformOrigin = 'center top';
if (window.getComputedStyle(card).position === 'static') {
card.style.position = 'relative';
}
if (config.darken && !card._darkenOverlay) {
const overlay = document.createElement('div');
overlay.style.position = 'absolute';
overlay.style.top = '0';
overlay.style.left = '0';
overlay.style.width = '100%';
overlay.style.height = '100%';
overlay.style.backgroundColor = 'rgba(0, 0, 0, 0)';
overlay.style.transition = `background-color ${config.animationDuration}s ease`;
overlay.style.pointerEvents = 'none';
overlay.style.zIndex = '1';
overlay.style.borderRadius = 'inherit';
overlay.className = 'scrollstack-card-overlay';
if (card.firstChild) {
card.insertBefore(overlay, card.firstChild);
} else {
card.appendChild(overlay);
}
card._darkenOverlay = overlay;
Array.from(card.children).forEach(child => {
if (child !== overlay) {
if (window.getComputedStyle(child).position === 'static') {
child.style.position = 'relative';
}
child.style.zIndex = '2';
}
});
}
card.dataset.initialTop = card.offsetTop;
});
}
function calculateStackingProgress(scrollY, triggerPoint, cardHeight) {
const longAnimationRange = cardHeight * 1.5;
const scrollPastTrigger = Math.max(0, scrollY - triggerPoint);
const progress = Math.min(scrollPastTrigger / longAnimationRange, 1);
return progress;
}
function applyVisualEffectsToParent(parentCard, childCard, progress, config) {
const isMobile = window.innerWidth < config.threshold;
if (config.disableMobile && isMobile) {
resetCardEffects(parentCard);
return;
}
const targetScale = parseFloat(parentCard.dataset.targetScale) || config.scale;
const scaleFactor = 1 - ((1 - targetScale) * progress);
let shadowOpacity = 0;
let shadowBlur = 0;
let shadowY = 0;
if (config.shadow) {
shadowOpacity = Math.min(0.25, 0.05 + (0.2 * progress));
shadowBlur = Math.min(20, 5 + (15 * progress)) * (isMobile ? 0.7 : 1);
shadowY = Math.min(15, 2 + (13 * progress)) * (isMobile ? 0.7 : 1);
}
const rotateX = config.rotate ? Math.min(5, 5 * progress) * (isMobile ? 0.7 : 1) : 0;
parentCard.style.transform = `scale(${scaleFactor}) ${config.rotate ? `rotateX(${rotateX}deg)` : ''}`;
if (config.shadow) {
parentCard.style.boxShadow = `0 ${shadowY}px ${shadowBlur}px rgba(0, 0, 0, ${shadowOpacity})`;
} else {
parentCard.style.boxShadow = '';
}
if (config.darken && parentCard._darkenOverlay) {
if (!isMobile) {
const darkness = Math.min(0.2, 0.05 + (0.15 * progress));
parentCard._darkenOverlay.style.backgroundColor = `rgba(0, 0, 0, ${darkness})`;
} else {
parentCard._darkenOverlay.style.backgroundColor = 'rgba(0, 0, 0, 0)';
}
}
}
function resetCardEffects(card) {
card.style.transform = 'scale(1) rotateX(0deg)';
card.style.boxShadow = '';
if (card._darkenOverlay) {
card._darkenOverlay.style.backgroundColor = 'rgba(0, 0, 0, 0)';
}
}
function getCardStackStart(card, config) {
return parseInt(card.dataset.initialTop) - config.padding;
}
function handleScrollEvent(container, cards, config) {
const isMobile = window.innerWidth < config.threshold;
if ((config.disabled) || (config.disableMobile && isMobile)) {
cards.forEach(card => {
card.style.position = 'relative';
card.style.top = '0';
resetCardEffects(card);
});
return;
}
const scrollY = window.scrollY;
const lastCardIndex = cards.length - 1;
cards.forEach((card, index) => {
const cardHeight = parseInt(card.dataset.originalHeight);
const stackStart = getCardStackStart(card, config);
if (scrollY >= stackStart) {
card.style.position = 'sticky';
const offsetAdjustment = isMobile ? config.mobileScale : 1;
const offset = (config.offset + (index * config.staggerOffset)) * offsetAdjustment;
card.style.top = `${offset}px`;
} else {
card.style.position = 'relative';
card.style.top = '0';
resetCardEffects(card);
}
});
for (let i = 0; i < cards.length; i++) {
const card = cards[i];
if (i < lastCardIndex) {
const nextCard = cards[i + 1];
if (nextCard.style.position === 'sticky') {
const cardHeight = parseInt(card.dataset.originalHeight);
const nextCardStackStart = getCardStackStart(nextCard, config);
const progress = calculateStackingProgress(scrollY, nextCardStackStart, cardHeight);
applyVisualEffectsToParent(card, nextCard, progress, config);
}
}
}
}
function shouldReduceEffects() {
const navigatorInfo = window.navigator;
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigatorInfo.userAgent);
const isSlowConnection = navigatorInfo.connection &&
(navigatorInfo.connection.saveData === true ||
['slow-2g', '2g', '3g'].includes(navigatorInfo.connection.effectiveType));
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
return isMobile || isSlowConnection || prefersReducedMotion;
}
function initWithAccessibility() {
if (shouldReduceEffects()) {
config.shadow = false;
config.rotate = false;
config.animationDuration = 0.4;
config.staggerOffset = 10;
}
initScrollStack();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initWithAccessibility);
} else {
initWithAccessibility();
}
window.scrollStack = {
init: initWithAccessibility,
toggleAnimations: function(enable) {
const containers = document.querySelectorAll('[data-scrollstack-container]');
containers.forEach(container => {
container.setAttribute('data-scrollstack-disabled', enable ? 'false' : 'true');
this.init();
});
},
toggleMobileAnimations: function(enable) {
const containers = document.querySelectorAll('[data-scrollstack-container]');
containers.forEach(container => {
container.setAttribute('data-scrollstack-disable-mobile', enable ? 'false' : 'true');
this.init();
});
}
};
})();Herramientas profesionales diseñadas para maximizar tus conversiones y simplificar la gestión de pagos

Solicitud de Documentación.
Revisión y validación de documentación.
Revisión de volumen mensual de operación.
Análisis de riesgo.
Oferta Comercial.
Firma de Contrato de Servicios.

Envío de Documentación de API de integración.
Ejecución de Pruebas transaccionales de Venta, Devolución y cancelación.
Validación de script de pruebas.
Configuración ambiente de Producción.

Liberación de credenciales de producción.
Ejecución de pruebas controladas.
Validación de compensación de transacciones de prueba.
Liquidación de operaciones.
Soporte especializado.
¿Necesitas un plan personalizado? Tenemos soluciones flexibles para tu negocio.
Contactar equipo de ventasCredencial de elector, pasaporte o cédula profesional del representante o apoderado legal.
Comprobante de domicilio del establecimiento/localRecibo de luz, agua, teléfono, gas y/o internet (con antigüedad no mayor a 2 meses).
Nota: el estado de cuenta bancario no aplica como soporte de domicilio.
Documento con datos de socios que forman la empresa
y están facultados para firmas en procesos legales.
(Aplica en personas morales)
Documento Alta ante el SAT con datos de actividad o giro comercial del cliente.
URL del comercioDirección en internet o página web en donde el comercio promociona sus servicios.
(function() {
const config = {
offset: 40,
padding: 50,
anchor: 'top',
disabled: false,
threshold: 768,
scale: 0.92,
scaleIncrement: 0.04,
shadow: true,
darken: true,
rotate: true,
perspective: 1000,
animationDuration: 2,
staggerOffset: 18,
mobileScale: 0.6,
disableMobile: false
};
const state = {
lastScrollY: 0,
scrollDirection: 'down'
};
function initScrollStack() {
const containers = document.querySelectorAll('[data-scrollstack-container]');
if (!containers.length) return;
containers.forEach(container => {
const cards = Array.from(container.querySelectorAll('[data-scrollstack-card]'));
if (!cards.length) return;
const containerConfig = {
offset: parseFloat(container.getAttribute('data-scrollstack-offset')) || config.offset,
padding: parseFloat(container.getAttribute('data-scrollstack-padding')) || config.padding,
anchor: container.getAttribute('data-scrollstack-anchor') || config.anchor,
disabled: container.getAttribute('data-scrollstack-disabled') === 'true' || config.disabled,
threshold: parseInt(container.getAttribute('data-scrollstack-threshold')) || config.threshold,
scale: parseFloat(container.getAttribute('data-scrollstack-scale')) || config.scale,
scaleIncrement: parseFloat(container.getAttribute('data-scrollstack-scale-increment')) || config.scaleIncrement,
shadow: container.getAttribute('data-scrollstack-shadow') !== 'false' && config.shadow,
darken: container.getAttribute('data-scrollstack-darken') !== 'false' && config.darken,
rotate: container.getAttribute('data-scrollstack-rotate') !== 'false' && config.rotate,
perspective: parseInt(container.getAttribute('data-scrollstack-perspective')) || config.perspective,
animationDuration: parseFloat(container.getAttribute('data-scrollstack-animation-duration')) || config.animationDuration,
staggerOffset: parseFloat(container.getAttribute('data-scrollstack-stagger-offset')) || config.staggerOffset,
mobileScale: parseFloat(container.getAttribute('data-scrollstack-mobile-scale')) || config.mobileScale,
disableMobile: container.getAttribute('data-scrollstack-disable-mobile') === 'true' || config.disableMobile
};
if (containerConfig.rotate) {
container.style.perspective = `${containerConfig.perspective}px`;
}
setupCards(container, cards, containerConfig);
handleScrollEvent(container, cards, containerConfig);
window.addEventListener('scroll', () => {
const currentScrollY = window.scrollY;
state.scrollDirection = currentScrollY > state.lastScrollY ? 'down' : 'up';
state.lastScrollY = currentScrollY;
handleScrollEvent(container, cards, containerConfig);
});
let resizeTimeout;
window.addEventListener('resize', () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
setupCards(container, cards, containerConfig);
handleScrollEvent(container, cards, containerConfig);
}, 200);
});
});
}
function setupCards(container, cards, config) {
const isMobile = window.innerWidth < config.threshold;
if ((config.disabled) || (config.disableMobile && isMobile)) {
cards.forEach(card => {
card.style.position = '';
card.style.top = '';
card.style.zIndex = '';
card.style.transform = '';
card.style.boxShadow = '';
card.style.transition = '';
if (card._darkenOverlay) {
card.removeChild(card._darkenOverlay);
delete card._darkenOverlay;
}
});
return;
}
container.dataset.containerHeight = container.offsetHeight;
cards.forEach((card, index) => {
const targetScale = Math.max(0.7, 1 - (index * config.scaleIncrement));
card.dataset.targetScale = targetScale;
if (!card.dataset.originalHeight) {
card.dataset.originalHeight = card.offsetHeight;
card.dataset.originalWidth = card.offsetWidth;
}
card.style.position = 'relative';
card.style.zIndex = cards.length - index;
card.style.transition = `transform ${config.animationDuration}s ease, box-shadow ${config.animationDuration}s ease`;
card.style.willChange = 'transform, box-shadow';
card.style.transformOrigin = 'center top';
if (window.getComputedStyle(card).position === 'static') {
card.style.position = 'relative';
}
if (config.darken && !card._darkenOverlay) {
const overlay = document.createElement('div');
overlay.style.position = 'absolute';
overlay.style.top = '0';
overlay.style.left = '0';
overlay.style.width = '100%';
overlay.style.height = '100%';
overlay.style.backgroundColor = 'rgba(0, 0, 0, 0)';
overlay.style.transition = `background-color ${config.animationDuration}s ease`;
overlay.style.pointerEvents = 'none';
overlay.style.zIndex = '1';
overlay.style.borderRadius = 'inherit';
overlay.className = 'scrollstack-card-overlay';
if (card.firstChild) {
card.insertBefore(overlay, card.firstChild);
} else {
card.appendChild(overlay);
}
card._darkenOverlay = overlay;
Array.from(card.children).forEach(child => {
if (child !== overlay) {
if (window.getComputedStyle(child).position === 'static') {
child.style.position = 'relative';
}
child.style.zIndex = '2';
}
});
}
card.dataset.initialTop = card.offsetTop;
});
}
function calculateStackingProgress(scrollY, triggerPoint, cardHeight) {
const longAnimationRange = cardHeight * 1.5;
const scrollPastTrigger = Math.max(0, scrollY - triggerPoint);
const progress = Math.min(scrollPastTrigger / longAnimationRange, 1);
return progress;
}
function applyVisualEffectsToParent(parentCard, childCard, progress, config) {
const isMobile = window.innerWidth < config.threshold;
if (config.disableMobile && isMobile) {
resetCardEffects(parentCard);
return;
}
const targetScale = parseFloat(parentCard.dataset.targetScale) || config.scale;
const scaleFactor = 1 - ((1 - targetScale) * progress);
let shadowOpacity = 0;
let shadowBlur = 0;
let shadowY = 0;
if (config.shadow) {
shadowOpacity = Math.min(0.25, 0.05 + (0.2 * progress));
shadowBlur = Math.min(20, 5 + (15 * progress)) * (isMobile ? 0.7 : 1);
shadowY = Math.min(15, 2 + (13 * progress)) * (isMobile ? 0.7 : 1);
}
const rotateX = config.rotate ? Math.min(5, 5 * progress) * (isMobile ? 0.7 : 1) : 0;
parentCard.style.transform = `scale(${scaleFactor}) ${config.rotate ? `rotateX(${rotateX}deg)` : ''}`;
if (config.shadow) {
parentCard.style.boxShadow = `0 ${shadowY}px ${shadowBlur}px rgba(0, 0, 0, ${shadowOpacity})`;
} else {
parentCard.style.boxShadow = '';
}
if (config.darken && parentCard._darkenOverlay) {
if (!isMobile) {
const darkness = Math.min(0.2, 0.05 + (0.15 * progress));
parentCard._darkenOverlay.style.backgroundColor = `rgba(0, 0, 0, ${darkness})`;
} else {
parentCard._darkenOverlay.style.backgroundColor = 'rgba(0, 0, 0, 0)';
}
}
}
function resetCardEffects(card) {
card.style.transform = 'scale(1) rotateX(0deg)';
card.style.boxShadow = '';
if (card._darkenOverlay) {
card._darkenOverlay.style.backgroundColor = 'rgba(0, 0, 0, 0)';
}
}
function getCardStackStart(card, config) {
return parseInt(card.dataset.initialTop) - config.padding;
}
function handleScrollEvent(container, cards, config) {
const isMobile = window.innerWidth < config.threshold;
if ((config.disabled) || (config.disableMobile && isMobile)) {
cards.forEach(card => {
card.style.position = 'relative';
card.style.top = '0';
resetCardEffects(card);
});
return;
}
const scrollY = window.scrollY;
const lastCardIndex = cards.length - 1;
cards.forEach((card, index) => {
const cardHeight = parseInt(card.dataset.originalHeight);
const stackStart = getCardStackStart(card, config);
if (scrollY >= stackStart) {
card.style.position = 'sticky';
const offsetAdjustment = isMobile ? config.mobileScale : 1;
const offset = (config.offset + (index * config.staggerOffset)) * offsetAdjustment;
card.style.top = `${offset}px`;
} else {
card.style.position = 'relative';
card.style.top = '0';
resetCardEffects(card);
}
});
for (let i = 0; i < cards.length; i++) {
const card = cards[i];
if (i < lastCardIndex) {
const nextCard = cards[i + 1];
if (nextCard.style.position === 'sticky') {
const cardHeight = parseInt(card.dataset.originalHeight);
const nextCardStackStart = getCardStackStart(nextCard, config);
const progress = calculateStackingProgress(scrollY, nextCardStackStart, cardHeight);
applyVisualEffectsToParent(card, nextCard, progress, config);
}
}
}
}
function shouldReduceEffects() {
const navigatorInfo = window.navigator;
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigatorInfo.userAgent);
const isSlowConnection = navigatorInfo.connection &&
(navigatorInfo.connection.saveData === true ||
['slow-2g', '2g', '3g'].includes(navigatorInfo.connection.effectiveType));
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
return isMobile || isSlowConnection || prefersReducedMotion;
}
function initWithAccessibility() {
if (shouldReduceEffects()) {
config.shadow = false;
config.rotate = false;
config.animationDuration = 0.4;
config.staggerOffset = 10;
}
initScrollStack();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initWithAccessibility);
} else {
initWithAccessibility();
}
window.scrollStack = {
init: initWithAccessibility,
toggleAnimations: function(enable) {
const containers = document.querySelectorAll('[data-scrollstack-container]');
containers.forEach(container => {
container.setAttribute('data-scrollstack-disabled', enable ? 'false' : 'true');
this.init();
});
},
toggleMobileAnimations: function(enable) {
const containers = document.querySelectorAll('[data-scrollstack-container]');
containers.forEach(container => {
container.setAttribute('data-scrollstack-disable-mobile', enable ? 'false' : 'true');
this.init();
});
}
};
})();(function() {
const config = {
offset: 40,
padding: 50,
anchor: 'top',
disabled: false,
threshold: 768,
scale: 0.92,
scaleIncrement: 0.04,
shadow: true,
darken: true,
rotate: true,
perspective: 1000,
animationDuration: 2,
staggerOffset: 18,
mobileScale: 0.6,
disableMobile: false
};
const state = {
lastScrollY: 0,
scrollDirection: 'down'
};
function initScrollStack() {
const containers = document.querySelectorAll('[data-scrollstack-container]');
if (!containers.length) return;
containers.forEach(container => {
const cards = Array.from(container.querySelectorAll('[data-scrollstack-card]'));
if (!cards.length) return;
const containerConfig = {
offset: parseFloat(container.getAttribute('data-scrollstack-offset')) || config.offset,
padding: parseFloat(container.getAttribute('data-scrollstack-padding')) || config.padding,
anchor: container.getAttribute('data-scrollstack-anchor') || config.anchor,
disabled: container.getAttribute('data-scrollstack-disabled') === 'true' || config.disabled,
threshold: parseInt(container.getAttribute('data-scrollstack-threshold')) || config.threshold,
scale: parseFloat(container.getAttribute('data-scrollstack-scale')) || config.scale,
scaleIncrement: parseFloat(container.getAttribute('data-scrollstack-scale-increment')) || config.scaleIncrement,
shadow: container.getAttribute('data-scrollstack-shadow') !== 'false' && config.shadow,
darken: container.getAttribute('data-scrollstack-darken') !== 'false' && config.darken,
rotate: container.getAttribute('data-scrollstack-rotate') !== 'false' && config.rotate,
perspective: parseInt(container.getAttribute('data-scrollstack-perspective')) || config.perspective,
animationDuration: parseFloat(container.getAttribute('data-scrollstack-animation-duration')) || config.animationDuration,
staggerOffset: parseFloat(container.getAttribute('data-scrollstack-stagger-offset')) || config.staggerOffset,
mobileScale: parseFloat(container.getAttribute('data-scrollstack-mobile-scale')) || config.mobileScale,
disableMobile: container.getAttribute('data-scrollstack-disable-mobile') === 'true' || config.disableMobile
};
if (containerConfig.rotate) {
container.style.perspective = `${containerConfig.perspective}px`;
}
setupCards(container, cards, containerConfig);
handleScrollEvent(container, cards, containerConfig);
window.addEventListener('scroll', () => {
const currentScrollY = window.scrollY;
state.scrollDirection = currentScrollY > state.lastScrollY ? 'down' : 'up';
state.lastScrollY = currentScrollY;
handleScrollEvent(container, cards, containerConfig);
});
let resizeTimeout;
window.addEventListener('resize', () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
setupCards(container, cards, containerConfig);
handleScrollEvent(container, cards, containerConfig);
}, 200);
});
});
}
function setupCards(container, cards, config) {
const isMobile = window.innerWidth < config.threshold;
if ((config.disabled) || (config.disableMobile && isMobile)) {
cards.forEach(card => {
card.style.position = '';
card.style.top = '';
card.style.zIndex = '';
card.style.transform = '';
card.style.boxShadow = '';
card.style.transition = '';
if (card._darkenOverlay) {
card.removeChild(card._darkenOverlay);
delete card._darkenOverlay;
}
});
return;
}
container.dataset.containerHeight = container.offsetHeight;
cards.forEach((card, index) => {
const targetScale = Math.max(0.7, 1 - (index * config.scaleIncrement));
card.dataset.targetScale = targetScale;
if (!card.dataset.originalHeight) {
card.dataset.originalHeight = card.offsetHeight;
card.dataset.originalWidth = card.offsetWidth;
}
card.style.position = 'relative';
card.style.zIndex = cards.length - index;
card.style.transition = `transform ${config.animationDuration}s ease, box-shadow ${config.animationDuration}s ease`;
card.style.willChange = 'transform, box-shadow';
card.style.transformOrigin = 'center top';
if (window.getComputedStyle(card).position === 'static') {
card.style.position = 'relative';
}
if (config.darken && !card._darkenOverlay) {
const overlay = document.createElement('div');
overlay.style.position = 'absolute';
overlay.style.top = '0';
overlay.style.left = '0';
overlay.style.width = '100%';
overlay.style.height = '100%';
overlay.style.backgroundColor = 'rgba(0, 0, 0, 0)';
overlay.style.transition = `background-color ${config.animationDuration}s ease`;
overlay.style.pointerEvents = 'none';
overlay.style.zIndex = '1';
overlay.style.borderRadius = 'inherit';
overlay.className = 'scrollstack-card-overlay';
if (card.firstChild) {
card.insertBefore(overlay, card.firstChild);
} else {
card.appendChild(overlay);
}
card._darkenOverlay = overlay;
Array.from(card.children).forEach(child => {
if (child !== overlay) {
if (window.getComputedStyle(child).position === 'static') {
child.style.position = 'relative';
}
child.style.zIndex = '2';
}
});
}
card.dataset.initialTop = card.offsetTop;
});
}
function calculateStackingProgress(scrollY, triggerPoint, cardHeight) {
const longAnimationRange = cardHeight * 1.5;
const scrollPastTrigger = Math.max(0, scrollY - triggerPoint);
const progress = Math.min(scrollPastTrigger / longAnimationRange, 1);
return progress;
}
function applyVisualEffectsToParent(parentCard, childCard, progress, config) {
const isMobile = window.innerWidth < config.threshold;
if (config.disableMobile && isMobile) {
resetCardEffects(parentCard);
return;
}
const targetScale = parseFloat(parentCard.dataset.targetScale) || config.scale;
const scaleFactor = 1 - ((1 - targetScale) * progress);
let shadowOpacity = 0;
let shadowBlur = 0;
let shadowY = 0;
if (config.shadow) {
shadowOpacity = Math.min(0.25, 0.05 + (0.2 * progress));
shadowBlur = Math.min(20, 5 + (15 * progress)) * (isMobile ? 0.7 : 1);
shadowY = Math.min(15, 2 + (13 * progress)) * (isMobile ? 0.7 : 1);
}
const rotateX = config.rotate ? Math.min(5, 5 * progress) * (isMobile ? 0.7 : 1) : 0;
parentCard.style.transform = `scale(${scaleFactor}) ${config.rotate ? `rotateX(${rotateX}deg)` : ''}`;
if (config.shadow) {
parentCard.style.boxShadow = `0 ${shadowY}px ${shadowBlur}px rgba(0, 0, 0, ${shadowOpacity})`;
} else {
parentCard.style.boxShadow = '';
}
if (config.darken && parentCard._darkenOverlay) {
if (!isMobile) {
const darkness = Math.min(0.2, 0.05 + (0.15 * progress));
parentCard._darkenOverlay.style.backgroundColor = `rgba(0, 0, 0, ${darkness})`;
} else {
parentCard._darkenOverlay.style.backgroundColor = 'rgba(0, 0, 0, 0)';
}
}
}
function resetCardEffects(card) {
card.style.transform = 'scale(1) rotateX(0deg)';
card.style.boxShadow = '';
if (card._darkenOverlay) {
card._darkenOverlay.style.backgroundColor = 'rgba(0, 0, 0, 0)';
}
}
function getCardStackStart(card, config) {
return parseInt(card.dataset.initialTop) - config.padding;
}
function handleScrollEvent(container, cards, config) {
const isMobile = window.innerWidth < config.threshold;
if ((config.disabled) || (config.disableMobile && isMobile)) {
cards.forEach(card => {
card.style.position = 'relative';
card.style.top = '0';
resetCardEffects(card);
});
return;
}
const scrollY = window.scrollY;
const lastCardIndex = cards.length - 1;
cards.forEach((card, index) => {
const cardHeight = parseInt(card.dataset.originalHeight);
const stackStart = getCardStackStart(card, config);
if (scrollY >= stackStart) {
card.style.position = 'sticky';
const offsetAdjustment = isMobile ? config.mobileScale : 1;
const offset = (config.offset + (index * config.staggerOffset)) * offsetAdjustment;
card.style.top = `${offset}px`;
} else {
card.style.position = 'relative';
card.style.top = '0';
resetCardEffects(card);
}
});
for (let i = 0; i < cards.length; i++) {
const card = cards[i];
if (i < lastCardIndex) {
const nextCard = cards[i + 1];
if (nextCard.style.position === 'sticky') {
const cardHeight = parseInt(card.dataset.originalHeight);
const nextCardStackStart = getCardStackStart(nextCard, config);
const progress = calculateStackingProgress(scrollY, nextCardStackStart, cardHeight);
applyVisualEffectsToParent(card, nextCard, progress, config);
}
}
}
}
function shouldReduceEffects() {
const navigatorInfo = window.navigator;
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigatorInfo.userAgent);
const isSlowConnection = navigatorInfo.connection &&
(navigatorInfo.connection.saveData === true ||
['slow-2g', '2g', '3g'].includes(navigatorInfo.connection.effectiveType));
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
return isMobile || isSlowConnection || prefersReducedMotion;
}
function initWithAccessibility() {
if (shouldReduceEffects()) {
config.shadow = false;
config.rotate = false;
config.animationDuration = 0.4;
config.staggerOffset = 10;
}
initScrollStack();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initWithAccessibility);
} else {
initWithAccessibility();
}
window.scrollStack = {
init: initWithAccessibility,
toggleAnimations: function(enable) {
const containers = document.querySelectorAll('[data-scrollstack-container]');
containers.forEach(container => {
container.setAttribute('data-scrollstack-disabled', enable ? 'false' : 'true');
this.init();
});
},
toggleMobileAnimations: function(enable) {
const containers = document.querySelectorAll('[data-scrollstack-container]');
containers.forEach(container => {
container.setAttribute('data-scrollstack-disable-mobile', enable ? 'false' : 'true');
this.init();
});
}
};
})();