Styling Cusdis: Taking Control of the Iframe Without Touching the Source
Table of Contents
Introduction#
I recently looked into implementing a comment system based on Cusdis. It’s a lightweight and privacy-friendly solution, but it comes with a UI challenge: the widget renders inside an iframe.
What does this mean for us? An iframe creates an isolated environment. The CSS styles we have in our project don’t leak inside. As a result, if our layout has specific branding, the default, minimalist Cusdis design looks like a part of something else.
I analyzed this issue and found a way to bypass it while keeping the code clean.
Injecting Styles#
I noticed that Cusdis renders the frame in a specific way. Instead of using a src attribute pointing to an external URL, it uses srcdoc. This embeds the widget’s HTML code directly into the attribute as a string.
This is a key difference. Because of this, the iframe inherits the Origin of our main page, instead of creating its own. In practice, this means we are not blocked by the CORS policy. We can access the contentDocument object inside the frame and inject our own rules there.
Here is a mechanism that relies on listening for when the frame is ready.
const iframe = document.querySelector('#cusdis_thread iframe');
iframe.addEventListener('load', () => {
// I set a delay to give time for external Cusdis resources to load
setTimeout(() => {
try {
const doc = iframe.contentDocument || iframe.contentWindow?.document;
if (!doc?.head) return;
// I prevent duplicating styles
if (doc.getElementById('my-custom-styles')) return;
const style = doc.createElement('style');
style.id = 'my-custom-styles';
style.textContent = `
/* We overwrite styles using !important to win priority */
body {
font-family: inherit !important;
background: #1a1a1a !important;
}
`;
doc.head.appendChild(style);
} catch (e) {
// Catching potential access errors
console.error('Failed to inject styles:', e);
}
}, 100);
});
Why the setTimeout and the load event? Cusdis loads its own scripts and styles asynchronously. If we inject our CSS too early, it will be overwritten by the widget’s original styles, which will load a fraction of a second later.
By injecting styles after the load event and adding them at the very end of the <head> section, we ensure our rules win in the style cascade.
Handling Dynamic Loading (SPA)#
In modern applications, especially SPAs (Single Page Applications), the iframe might not exist when the page loads. It might appear later, for example, after navigating to an article subpage.
MutationObserver allows us to listen for changes in the DOM tree and react exactly when the widget is attached to the page structure.
const container = document.querySelector('#cusdis_thread');
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.tagName === 'IFRAME') {
// We got it! We attach our listener
node.addEventListener('load', () => injectStyles(node));
}
}
}
});
observer.observe(container, { childList: true, subtree: true });
Synchronizing with the Site Theme (Dark Mode)#
Personally, I don’t like hardcoding values. If we change the background color in the main theme, we don’t want to have to remember to manually update the comments script.
The solution is to grab the values directly from the CSS Custom Properties (CSS variables) of our site and pass them to the iframe.
function getThemeColors() {
const root = getComputedStyle(document.documentElement);
return {
background: root.getPropertyValue('--background').trim(),
foreground: root.getPropertyValue('--foreground').trim(),
accent: root.getPropertyValue('--accent').trim()
};
}
function generateCSS(colors) {
return `
body {
background: ${colors.background} !important;
color: ${colors.foreground} !important;
}
a, button {
color: ${colors.accent} !important;
}
`;
}
This way, the widget will automatically adapt to changes in your Design System.
Height Issues (Scrollbars)#
Another thing I noticed during testing was height issues. Theoretically, Cusdis uses postMessage to inform the parent page about its height, but in practice, it works inconsistently. Often, an ugly internal scrollbar would appear.
I decided to take control of this process. We have to calculate the height of the iframe’s content ourselves and force it on the element.
function updateHeight(iframe) {
try {
const doc = iframe.contentDocument || iframe.contentWindow?.document;
if (!doc?.body) return;
// I grab the maximum from three different metrics for cross-browser reliability
const height = Math.max(
doc.body.scrollHeight,
doc.documentElement.scrollHeight,
doc.body.offsetHeight
);
iframe.style.height = height + 'px';
} catch (e) {
// Fallback in case of an error
}
}
Observing Changes in Real Time#
Comments are a living organism—new posts arrive, and users click “reply.” A height calculated once at the beginning will quickly become outdated.
This is where ResizeObserver and, again, MutationObserver come to our rescue, attached to the inside of the frame.
function setupHeightObserver(iframe) {
try {
const doc = iframe.contentDocument || iframe.contentWindow?.document;
if (!doc?.body) return;
let timer;
const update = () => {
// Simple debounce to avoid killing performance with rapid changes
clearTimeout(timer);
timer = setTimeout(() => updateHeight(iframe), 100);
};
// Listen for window resize and DOM structure changes (new comments)
new ResizeObserver(update).observe(doc.body);
new MutationObserver(update).observe(doc.body, {
childList: true,
subtree: true,
attributes: true
});
} catch (e) {}
}
Ready Solution: Production Code#
I gathered all these elements into one self-sufficient module. You can paste it into your project. The script will detect when and if the widget has loaded, then apply styles and fix the height.
(function() {
const CONTAINER = '#cusdis_thread';
function injectStyles(iframe) {
iframe.addEventListener('load', () => {
setTimeout(() => {
try {
const doc = iframe.contentDocument || iframe.contentWindow?.document;
if (!doc?.head || doc.getElementById('cusdis-custom')) return;
const style = doc.createElement('style');
style.id = 'cusdis-custom';
style.textContent = `
/* Paste your styles or variable fetching logic here */
body { background: #1a1a1a !important; }
`;
doc.head.appendChild(style);
// We immediately start the height observer
setupHeightObserver(iframe);
} catch (e) {}
}, 100);
});
}
function updateHeight(iframe) {
try {
const doc = iframe.contentDocument || iframe.contentWindow?.document;
if (!doc?.body) return;
const h = Math.max(doc.body.scrollHeight, doc.documentElement.scrollHeight);
iframe.style.height = h + 'px';
} catch (e) {}
}
function setupHeightObserver(iframe) {
try {
const doc = iframe.contentDocument || iframe.contentWindow?.document;
if (!doc?.body) return;
let timer;
const update = () => {
clearTimeout(timer);
timer = setTimeout(() => updateHeight(iframe), 100);
};
new ResizeObserver(update).observe(doc.body);
new MutationObserver(update).observe(doc.body, {
childList: true, subtree: true, attributes: true
});
} catch (e) {}
}
function init() {
const container = document.querySelector(CONTAINER);
if (!container) return;
// Scenario 1: Iframe appears dynamically
new MutationObserver((mutations) => {
for (const m of mutations) {
for (const node of m.addedNodes) {
if (node.tagName === 'IFRAME') injectStyles(node);
}
}
}).observe(container, { childList: true, subtree: true });
// Scenario 2: Iframe is already there
const iframe = container.querySelector('iframe');
if (iframe) injectStyles(iframe);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
Takeaways#
Cusdis is a great tool, but styling it requires some JavaScript gymnastics. However, the srcdoc method gives us a backdoor that is worth exploiting.
In my opinion, the approach of injecting CSS via JS (JavaScript Injection) provides the best balance. On the one hand, we have full visual control and synchronization with the site theme. On the other hand, we don’t have to host our own Cusdis instance or modify the widget’s source code, which makes maintaining the project much easier in the future.