Los componentes de desplazamiento de texto, comúnmente conocidos como marquesinas o carruseles de notificaciones, son fundamentales para mostrar actualizaciones en tiempo real en interfaces de usuario. Este tipo de elementos dinámicos no solo optimizan el uso del espacio vertical en el diseño, sino que también incrementan la percepción de actividad, interactividad y confianza en la plataforma. A continuación, exploraremos dos enfoques técnicos para lograr este efecto: utilizando JavaScript puro mediante manipulación directa del DOM y mediante el ecosistema reactivo de Vue.js.
- Manipulación directa del DOM con JavaScript
La estrategia clásica en JavaScript vanilla implica duplicar el contenido del contenedor para crear un bucle infinito visual. Se controla la propiedad scrollTop del elemento padre para simular el movimiento continuo. A continuación, se presenta una implementación modernizada con sintaxis ES6 y una estructura más limpia.
<html lang="es">
<head>
<meta charset="UTF-8">
<style>
.notification-viewport {
height: 200px;
overflow: hidden;
position: relative;
border: 1px solid #e0e0e0;
border-radius: 8px;
}
.notification-list {
margin: 0;
padding: 0;
list-style: none;
}
.notification-item {
height: 40px;
line-height: 40px;
font-size: 14px;
padding: 0 15px;
box-sizing: border-box;
}
.highlight {
color: #f5a623;
font-weight: bold;
}
</style>
</head>
<body>
<div class="notification-viewport" id="viewport">
<ul class="notification-list" id="primaryTrack"></ul>
<ul class="notification-list" id="secondaryTrack"></ul>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
const viewport = document.getElementById('viewport');
const trackOne = document.getElementById('primaryTrack');
const trackTwo = document.getElementById('secondaryTrack');
const initialData = [
'Usuario ***834 ganó <span class="highlight">500€ en tarjetas</span> hace 1 min',
'Usuario ***274 ganó <span class="highlight">1000$ en bonos</span> hace 2 min',
'Usuario ***775 ganó <span class="highlight">500€ en tarjetas</span> hace 3 min',
'Usuario ***418 ganó <span class="highlight">Pack tecnológico</span> hace 5 min',
'Usuario ***548 ganó <span class="highlight">Termo premium</span> hace 8 min',
'Usuario ***737 ganó <span class="highlight">Kit de trading</span> hace 10 min'
];
const renderList = (element, data) => {
element.innerHTML = data.map(item => `<li class="notification-item">${item}</li>`).join('');
};
renderList(trackOne, initialData);
trackTwo.innerHTML = trackOne.innerHTML;
let scrollPosition = 0;
const totalHeight = trackOne.scrollHeight;
setInterval(() => {
scrollPosition += 1;
if (scrollPosition >= totalHeight) {
scrollPosition = 0;
}
viewport.scrollTop = scrollPosition;
}, 30);
});
</script>
</body>
</html>
- Enfoque reactivo con Vue.js
En los frameworks modernos, es preferible manipular el estado de los datos y permitir que el motor de renderizado actualice el DOM. Para lograr un efecto de desplazamiento suave, podemos utilizar la directiva <transition-group> de Vue combinada con operaciones de índice en matrices, lo que resulta en un código más mantenible y declarativo.
<template>
<div class="ticker-container">
<transition-group name="ticker-fade" tag="ul" class="ticker-list">
<li v-for="record in visibleRecords" :key="record.id" class="ticker-item">
<span class="time-badge">{{ record.timestamp }}</span>
<span class="user-info">Usuario {{ record.maskedPhone }} diagnosticó</span>
<span class="company-name">{{ record.company }}</span>
</li>
</transition-group>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue';
const generateMaskedPhone = () => {
const prefixes = ['34', '35', '38', '58'];
const prefix = prefixes[Math.floor(Math.random() * prefixes.length)];
const suffix = Math.floor(Math.random() * 10000).toString().padStart(4, '0');
return `${prefix}****${suffix}`;
};
const initialRecords = [
{ id: 1, timestamp: 'Hace 2s', company: 'Hikvision (002415)' },
{ id: 2, timestamp: 'Hace 8s', company: 'FiberHome (600498)' },
{ id: 3, timestamp: 'Hace 3m', company: 'Hengli Petrochemical (000703)' },
{ id: 4, timestamp: 'Hace 5m', company: 'Unigroup Guoxin (002049)' },
{ id: 5, timestamp: 'Hace 39m', company: 'Xintian Tech (300259)' },
{ id: 6, timestamp: 'Hace 1h', company: 'Industrial Bank (601166)' },
{ id: 7, timestamp: 'Hace 3h', company: 'China Nuclear Eng (601611)' }
];
const records = ref(initialRecords.map(r => ({ ...r, maskedPhone: generateMaskedPhone() })));
const visibleRecords = ref(records.value.slice(0, 3));
let intervalId = null;
let currentIndex = 0;
const rotateRecords = () => {
currentIndex = (currentIndex + 1) % records.value.length;
const nextIndex = (currentIndex + 1) % records.value.length;
const nextNextIndex = (currentIndex + 2) % records.value.length;
visibleRecords.value = [
records.value[currentIndex],
records.value[nextIndex],
records.value[nextNextIndex]
];
};
onMounted(() => {
intervalId = setInterval(rotateRecords, 2500);
});
onBeforeUnmount(() => {
if (intervalId) clearInterval(intervalId);
});
</script>
<style scoped>
.ticker-container {
height: 120px;
overflow: hidden;
background: #f9f9f9;
border-radius: 6px;
}
.ticker-list {
list-style: none;
padding: 0;
margin: 0;
}
.ticker-item {
height: 40px;
line-height: 40px;
display: flex;
align-items: center;
gap: 12px;
padding: 0 16px;
font-size: 14px;
color: #333;
}
.time-badge {
color: #888;
min-width: 60px;
}
.company-name {
color: #1890ff;
font-weight: 500;
}
.ticker-fade-enter-active, .ticker-fade-leave-active {
transition: all 0.6s ease;
}
.ticker-fade-enter-from {
opacity: 0;
transform: translateY(20px);
}
.ticker-fade-leave-to {
opacity: 0;
transform: translateY(-20px);
position: absolute;
width: 100%;
}
</style>