Just to put as answer:
First of, the main issue here is that all your elements are visible by default, and than you're calling "collapse" on DOM ready via JavaScript on thousands of elements. The browser fights to close item by item and recalculate the elements flow, repaint and render.
There's no such framework to help you - if you're doing it the wrong way.
Rather, hide all elements by default via CSS, not via JS - and only on click (or whatever) open the target element.
Secondly, there's lazy loading of DOM elements via AJAX - depending on the scroll, or as suggested pagination is also an amazing option.
Modern web compilers, frameworks, like Svelte, Vue, React can help you track a state of your current data, but then the principle is the same, framework or no framework, on scroll AJAX is the one that will trigger a HTTP request for more data, the framework will only help in effortlessly update the view as the data updates. And PS, 15 seconds is even for a business tailored application way too much time.
So to recap 5000 elements should not be a major issue for a modern browser. Just invert your logic. Hide using CSS by default, Use collapse-expand only when a user action is registered.
Example 1
showing that even 5000 collapsible elements or more than 80000 DOM elements are no issue for the browser if done right:
const tpl_collapse = (item) => `
<div class="Collapse">
<div class="Collapse-head">${item.i}. Character: &#x${item.hex};</div>
<div class="Collapse-body">
Hex: <code>&#x${item.hex};</code><br>
Num: <code>&#${item.i};</code><br>
CSS/JS: <code>\\${item.hex}</code><br>
</div>
</div>`;
const data = [...Array(10001).keys()].map((_, i) => ({i,hex: i.toString(16)}));
const accordions = data.map(tpl_collapse).join("");
const EL_container = document.querySelector("#container");
EL_container.innerHTML = accordions;
EL_container.addEventListener("click", (ev) => {
if (ev.target.closest(".Collapse-head")) {
ev.target.closest(".Collapse").classList.toggle("expanded");
}
})
.Collapse {
border: 1px solid #eee;
}
.Collapse-head {
cursor: pointer;
padding: 5px 10px;
}
.Collapse-body {
display: none;
background: #eee;
padding: 5px 10px;
}
.Collapse.expanded .Collapse-head {
background: #0bf;
}
.Collapse.expanded .Collapse-body {
display: block;
}
<div id="container"></div>
Example 2
Dynamically loading N collapsible elements:
If you have a good eye you might have noticed that the above took "only" a couple of seconds (on a relatively good desktop machine).
That's also unacceptable. So let's try to use a dynamic approach of fetching and creating elements!
One way is to assign to the most bottom element an Intersection Observer API. Once the page is scrolled and/or that element enters in viewport, simply load the next page (100 items-set, in this example).
You might use AJAX here, but I'll use pure code generation for this purpose. Anyways here's a proof of concept.
Notice the immediate rendering (no more wait time):
let page = 1; // Current page
const itemsPerPage = 100; // Items per page
const inViewport = (entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting && +entry.target.dataset.page === page) {
page += 1; // Increment page!
fetchPage(); // Fetch next page
}
});
};
const observer = new IntersectionObserver(inViewport);
const EL_container = document.querySelector("#container");
const NewEL = (tag, prop) => Object.assign(document.createElement(tag), prop);
const EL_item = (item) => {
const EL = NewEL("div", {
className: "Collapse",
innerHTML: `<div class="Collapse-head">${item.index}. Character: &#x${item.hex};</div>
<div class="Collapse-body">
Hex: <code>&#x${item.hex};</code><br>
Num: <code>&#${item.index};</code><br>
CSS/JS: <code>\\${item.hex}</code><br>
</div>`
});
EL.dataset.page = page; // Associate page to data-page attribute
// Observe only last element
if (!((item.index + 1) % itemsPerPage)) observer.observe(EL);
return EL;
}
// Just to programmatically emulate an AJAX call of N items in a page-range
const fetchPage = () => {
const DF_items = [...Array(itemsPerPage).keys()].reduce((DF, index) => {
const i = (page - 1) * itemsPerPage + index;
DF.append(EL_item({index: i,hex: i.toString(16), page}));
return DF;
}, new DocumentFragment());
EL_container.append(DF_items);
};
EL_container.addEventListener("click", (ev) => {
if (!ev.target.closest(".Collapse-head")) return;
ev.target.closest(".Collapse").classList.toggle("expanded");
});
// INIT!
fetchPage();
.Collapse {
border: 1px solid #eee;
}
.Collapse-head {
cursor: pointer;
padding: 5px 10px;
}
.Collapse-body {
display: none;
background: #eee;
padding: 5px 10px;
}
.Collapse.expanded .Collapse-head {
background: #0bf;
}
.Collapse.expanded .Collapse-body {
display: block;
}
<div id="container"></div>
Example 3
Pagination as the most common way to tackle this issue
let page = 1; // Current page
const itemsPerPage = 50; // Items per page
const EL_container = document.querySelector("#container");
const EL_prev = document.querySelector("#prev");
const EL_next = document.querySelector("#next");
const EL_goto = document.querySelector("#goto");
const NewEL = (tag, prop) => Object.assign(document.createElement(tag), prop);
const EL_item = (item) => {
return NewEL("div", {
className: "Collapse",
innerHTML: `<div class="Collapse-head">${item.index}. Character: &#x${item.hex};</div>
<div class="Collapse-body">
Hex: <code>&#x${item.hex};</code><br>
Num: <code>&#${item.index};</code><br>
CSS/JS: <code>\\${item.hex}</code><br>
</div>`
});
}
const fetchPage = () => {
page = Math.abs(page) || 1; // Fix for negative or 0 page number
const DF_items = [...Array(itemsPerPage).keys()].reduce((DF, index) => {
const i = (page - 1) * itemsPerPage + index;
DF.append(EL_item({index: i, hex: i.toString(16), page }));
return DF;
}, new DocumentFragment());
EL_container.innerHTML = "";
EL_container.append(DF_items);
EL_goto.value = page;
};
EL_container.addEventListener("click", (ev) => {
if (!ev.target.closest(".Collapse-head")) return;
ev.target.closest(".Collapse").classList.toggle("expanded");
});
EL_goto.addEventListener("input", () => {
page = parseInt(EL_goto.value, 10);
if (page) fetchPage();
});
EL_prev.addEventListener("click", () => {
page -= 1;
fetchPage();
});
EL_next.addEventListener("click", () => {
page += 1;
fetchPage();
});
// INIT!
fetchPage();
#container {
height: calc(100vh - 40px);
overflow: auto;
}
.Collapse {
border: 1px solid #eee;
}
.Collapse-head {
cursor: pointer;
padding: 5px 10px;
}
.Collapse-body {
display: none;
background: #eee;
padding: 5px 10px;
}
.Collapse.expanded .Collapse-head {
background: #0bf;
}
.Collapse.expanded .Collapse-body {
display: block;
}
#goto {
text-align:center;
width: 100px;
}
<button id="prev" type="button">PREV</button>
<input id="goto" type="number" value="1" min="1">
<button id="next" type="button">NEXT</button>
<div id="container"></div>