Mert
Almanya, sığınak bulma uygulaması ve 110 bin sahra yatağını içeren sivil savunma planı hazırlıyor
Rusya: Son 24 Saatte Ukrayna'ya Ait 1.045 İha'yı Düşürdük
The Backward Logic of Chickenpox Parties
ChatGPT, banka hesaplarının bağlanabildiği kişisel finans araçlarını kullanıma sundu
Japonya, ayı saldırılarını önlemek için “robot kurtlar” kullanmaya başladı
Japonya’da ayı saldırılarının rekor seviyeye ulaşması, “Monster Wolf” adlı robot kurtlara talebi patlattı. Üretici firma, art arda gelen siparişler nedeniyle talebe yetişemediğini duyurdu.Xiaomi, büyük etkinliği duyurdu: Xiaomi 17 Max, YU7 GT ve fazlası geliyor
Xiaomi, amiral gemisi Xiaomi 17 Max, yeni elektrikli SUV modeli Xiaomi YU7 GT, akıllı bileklik Xiaomi Band 10 Pro ve diğer ürünlerin 21 Mayıs’ta Çin’de piyasaya sürüleceğini resmen duyurdu.Subnautica 2’den etkileyici başlangıç: 12 saatte 2 milyon kopya sattı
Subnautica’nın yıllar sonra gelen devam oyunu şüpheleri boşa çıkardı. Yapım aşamasında yaşadığı sorunlar nedeniyle kafalarda soru işaretleri oluşturan oyun, tek kelimeyle muhteşem bir başlangıç yaptı.Why Your AI Voiceovers Sounds Robotic (And How to Fix Them)
You’ve perfected your script and video edit. You generate an AI voiceover and get disappointed. The words are correct, but something sounds off; flat pacing, wrong emphasis, and your brand name mangled beyond recognition.
The problem isn’t the AI model. It’s the lack of direction.
Why AI Sounds Robotic
Most text-to-speech (TTS) models work by predicting the statistically average way to say a word. They don’t understand emotional weight. They don’t know that “The house burned down” should sound different from “The toast burned down.”
Three main issues:
Lack of prosody: AI maintains flat pitch instead of varying it like humans do to signal meaning.
No natural breathing: Humans pause for breath. AI doesn’t. Your brain immediately flags this as “non-human.”
Contextual confusion: AI struggles with words that sound different based on context (like “read” vs “read”) and technical jargon.
1. Script Phonetically, Not Grammatically
Stop feeding raw blog posts into TTS engines. Words that look good on screen often sound terrible spoken aloud.
Phonetic spelling forces accuracy: If the AI says “SQL” as “sequel” but you need “S-Q-L,” rewrite it. For “N8N,” try “Nate-en” if you want it fluid.
Use punctuation strategically: Add more commas than you normally would to force pauses. Use ellipses (…) for soft, thoughtful pauses and dashes (—) for sharp breaks. This forces the AI to breathe.
Example:
- Bad: “Our SQL database is fast.”
- Better: “Our S-Q-L… database is incredibly fast.”
2. Use SSML If Your Tool Supports It
If you’re using API-based tools (Google Cloud TTS, Azure, or some premium platforms), use SSML (Speech Synthesis Markup Language). Think of it as CSS(Cascading Style Sheet) for voice, it gives you granular control.
Key SSML tags:
-
<break time="500ms"/>creates pauses between topics. -
<emphasis level="strong">highlights key points by increasing volume and pitch. -
<prosody rate="slow">slows down complex technical explanations. -
<say-as interpret-as="characters">spells out acronyms like “IBM” instead of pronouncing them.
Example:
<speak>
Welcome to the tutorial.
<break time="1s"/>
Today we'll learn about
<prosody pitch="+5%" rate="fast">high-performance computing.</prosody>
</speak>
Not all consumer tools support SSML, but if yours does, use it.
3. Choose the Right Voice Style for Your Content
This is where most creators fail. Many platforms offer voice styles but users treat them as “nice-to-have” rather than essential.
Google AI Studio Voice Generation feature offers practical voicing options: teacher, storyteller, podcaster, and so on. Each option has model settings like whisper, newscaster, promo, emphathetic, and so on. The problem? Even with style selection, the tone doesn’t always match content. A storyteller set as a newscaster voice reading an excited announcement might still sound sad.
ElevenLabs provides stability and style strength sliders. Low stability (30-40%) adds emotion but risks glitching. High stability (80-90%) sounds polished but robotic. Aim for 55-65% for natural narration.
OpenAI TTS prioritizes simplicity and speed—good for basic voiceovers but limited emotional control.
The real solution: Preview multiple styles before committing. If the default doesn’t match your content’s tone, try another voice entirely. Don’t force a sad newscaster voice to sound happy.
4. Add Post-Processing to Sound Human
Even great AI audio sounds sterile, it’s generated in silence with no natural imperfections. Fix this with post-processing.
Add ambient room tone: Layer a very subtle white noise or office ambience track at -50db to -60db under your voiceover. This makes it sound grounded in reality instead of floating in a digital void.
EQ and compress: AI voices often have piercing mid-high frequencies (2kHz–4kHz) that sound nasal. Cut these slightly. Use soft-knee compression to level out volume and remove that “raw API” sound.
Insert breath sounds: If your platform doesn’t include breathing sounds, manually add very short, quiet breath samples before long sentences. This tiny change tricks the human brain into accepting the voice as real.
5. Automate Script Preparation for Consistency
If you generate voiceovers regularly, automate the optimization process. Use tools like n8n or a simple Python script to pre-process scripts before TTS generation.
A basic workflow:
- Input: Raw script text
- Processing: An LLM (like GPT-4o) rewrites it for TTS—adding phonetic spellings, strategic punctuation, and SSML tags
- Generation: Send the optimized script to your TTS API
- Post-processing: Normalize audio, add room tone, apply EQ filters
-
Review: Have a human do a final quality check before publishing
This removes guesswork and ensures every voiceover meets your standards.
Real-World Problem: Tone Mismatch & Context Degradation
Look at Google AI Studio voice generation as a case study: a newscaster voice reading happy content in a sad tone reveals a fundamental limitation, AI doesn’t understand emotional context.
The temptation is to re-record with a different voice style, but that creates a new problem: if you’ve already generated half your script with one voice, switching to another will sound like two different people narrated it. That’s worse than tone mismatch.
Better solution: Accept the tone mismatch and fix it in post-production with background music, sound effects, or subtle audio layering. This maintains vocal consistency while compensating for emotional disconnect. Moreover, the next voiceover you generate after that could be much better.
The Context Rot Problem You’ve Likely Encountered
If you’ve fed long scripts or entire articles into a voice platform, you’ve probably noticed quality drops partway through. The model’s output degrades as input length increases, even when you haven’t hit the tool’s token limit. This is called context rot.
Research shows that when context exceeds 50% capacity, the model starts losing information from the beginning of the text, over-indexing on recent sections. Your voiceover quality doesn’t fail suddenly, it just slowly degrades, and you might not notice until you’ve already committed half the work.
Fix this permanently: Generate voiceovers at the sentence or paragraph level, not the entire script at once. Instead of feeding in a 2000-word article, break it into 100-150 word chunks and generate each separately. This keeps the AI’s attention sharp and prevents quality degradation.
Example workflow:
- Break your script into 5-6 sentence chunks
- Generate audio for each chunk
- Combine the audio files in your editor.
This takes some time but guarantees consistent quality throughout.
Voice Cracks, Artifacts, and Audio Roughness
Some platforms produce clicks, buzzing, and distortion artifacts that shouldn’t exist in synthetic speech. These typically occur when:
-
Pauses are too abrupt – The model struggles with sudden breaks and creates clicking sounds. Fix this by adding ellipses (…) instead of periods before natural pauses, or use SSML
<break>tags. - Long sentences without natural division – When the system fails to model proper intonation and pauses, it creates mispronunciations, awkward silences, or buzzy vowels. Break long sentences at commas.
- Special characters and emojis – Emojis don’t translate well into speech and may cause strange pronunciation or be skipped entirely. Remove them before generating.
-
Inconsistent audio levels – If the voiceover fluctuates in volume, it creates an impression of low quality. In post-processing, use gentle compression (soft-knee setting) to level out the audio.
If you encounter voice cracks despite clean formatting, the platform itself may have vocoder limitations. Try a different voice model on the same platform, models have different stability profiles. If the problem persists across multiple voices, the platform may need better training data.
Stop Expecting Mind-Reading
AI voiceovers sound robotic because creators treat TTS as a “push button and hope” process. It isn’t. The model needs direction.
Use phonetic spelling. Preview multiple voice styles. Add post-processing. Automate your workflow. These aren’t optional, they’re the difference between audio that sounds like a robot and audio that sounds human.
Start with phonetic spelling today. It’s the fastest way to improve quality without changing tools or adding complexity.
Have you dealt with tone mismatch in AI voiceovers? What’s your go-to platform? Share your experience in the comments.
How we engineered a better Next.js theme library
How we engineered a better Next.js theme library
There are three serious theme libraries for Next.js and React in 2026:
-
next-themesby paco — the OG. ~3 million weekly downloads. Battle-tested but unmaintained for React 19. -
@wrksz/themesby jakubwarkusz — a clean-slate rewrite from March 2026. ~10k weekly downloads. Aims to be a near drop-in replacement fornext-themeswith the bugs fixed. -
@teispace/next-themes— what my team ships. ~3.5k weekly downloads. Same goal as@wrksz/themes, different engineering choices.
I spent the last week doing a code-level audit of all three. Not just reading READMEs and comparing feature tables, but cloning the repos, reading every source file, and writing failing tests against each. This post is what I found.
The short version: every one of these libraries has real bugs that ship to production. Most of them are subtle — they don’t crash, they don’t error in dev, they just make your app worse in ways your users notice and your monitoring doesn’t catch. The kind of stuff where someone files a GitHub issue like “sometimes the page flashes dark for half a second on load” and the maintainer says “can’t reproduce” and closes it.
That’s the kind of bug I want to walk you through. With line numbers. From the real code.
I’ll cover five specific issues, then explain the architectural choice underneath each one. If you only read one section, make it #3 (the XSS in the inline script), because if you’ve shipped a next-themes competitor, you probably have it too.
Why the inline anti-FOUC script is the heart of everything
Before the bugs, you need to understand the constraint. A theme library has exactly one hard job:
Apply the right theme to
<html>before any pixels paint, even though the theme value lives in a cookie orlocalStoragethat React doesn’t read until after the document has streamed.
Every other feature — the React hook, the toggle button, the SSR helper — is decoration. If the inline script is wrong, the user sees a flash of the wrong theme on every page load, and no amount of clever React code fixes it.
The pattern every library uses is the same:
<head>
<script>
// ...read cookie or localStorage, compute theme, set class on <html>...
</script>
</head>
The script runs synchronously, before the browser parses any body content, before React hydrates, before anything else happens. The whole library exists to generate that script correctly and to keep the React state in sync with what the script did.
Now the bugs.
Bug #1: next-themes and the React 19 inline script warning
If you upgrade next-themes to a React 19 project, you get this in your dev console:
Warning: A
dangerouslySetInnerHTMLwas passed to a script element. This will trigger a Content Security Policy violation in development.
The root cause is in next-themes/src/index.tsx — the script is rendered as a literal <script dangerouslySetInnerHTML={{__html: ...}}> inside a React tree that hydrates on the client. React 19 tightened how it handles inline scripts during hydration because of an Edge-case bug where the script could re-execute. The library was never updated.
@wrksz/themes fixed this by using useServerInsertedHTML from next/navigation, which inserts the script as part of the SSR stream rather than as a hydrating React element:
// @wrksz/themes/src/providers/client-next-provider.tsx
useServerInsertedHTML(() => {
if (inserted.current) return null;
inserted.current = true;
return (
<script
dangerouslySetInnerHTML={{ __html: getScript({...}) }}
nonce={nonce}
/>
);
});
Ours uses the same primitive, but with one extra prop: noScript. The default behavior is what you’d expect — the provider injects the script via useServerInsertedHTML. But there’s a subtle problem with that placement: useServerInsertedHTML inserts at the next React flush boundary, which on streamed responses is usually inside <body>, after some pixels may have already painted.
So we also export a getThemeScript() helper from /server that lets you render the script directly in <head> of your root layout, then tell the provider to skip its own injection:
// app/layout.tsx
import { ThemeProvider } from '@teispace/next-themes';
import { getTheme, getThemeScript } from '@teispace/next-themes/server';
export default async function RootLayout({ children }) {
const initialTheme = await getTheme();
const themeScript = getThemeScript({ attribute: 'class', initialTheme });
return (
<html lang="en" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
</head>
<body>
<ThemeProvider attribute="class" initialTheme={initialTheme ?? undefined} noScript>
{children}
</ThemeProvider>
</body>
</html>
);
}
This guarantees the inline script runs before the browser paints any body pixels, regardless of how Next.js streams the response. The library handles both placements; we just believe the head placement is strictly better when you can do it.
Bug #2: __name(...) wrappers crashing the inline script
This one is genuinely sneaky.
When you write a named function in TypeScript and compile with esbuild or swc with keepNames: true (the default in most setups), the compiler injects a wrapper like this:
function themeScript(storageKey, attribute, ...) { ... }
// becomes:
var themeScript = __name(function(storageKey, attribute, ...) { ... }, "themeScript");
__name is a runtime helper esbuild injects at the top of the bundle. It just sets Object.defineProperty(fn, 'name', { value: 'themeScript' }). Harmless — except if you take that function and call .toString() on it, you get back source like:
__name(function(storageKey, attribute, ...) { ... }, "themeScript")
Now __name is a bundle-local identifier. It doesn’t exist on window. When you inject this string into an inline <script> tag, the browser sees ReferenceError: __name is not defined, the script throws, the theme is never applied, and your user sees the wrong-theme flash on every page load.
next-themes had this bug for months. So did @wrksz/themes — they have an explicit workaround in packages/themes/src/core/script.ts:145:
const fn = themeScript.toString().replace(/s*__names*([^)]*)s*;?s*/g, "");
A regex that strips __name(...) calls from the serialized function source. It works today, but it’s fragile by design: it only handles this exact wrapper pattern. If esbuild ever changes the wrapper format, or if swc starts emitting something different, or if the user has a custom Babel plugin that renames the helper — the script silently breaks again, and they ship FOUC to production.
Ours sidesteps the entire class of problem. From src/core/script.ts:55-67:
The script body is a raw string literal — NOT a serialized function. This is intentional: bundlers (esbuild, swc) inject
__name(fn,"name")wrappers around named functions whenkeepNamesis on, which crashes any deserialized function body that lacks a global__name. Storing the body as data means no bundler ever rewrites it.
The actual script is hand-written JavaScript stored as a const string. We pass the config in via a JSON object that’s interpolated at serialization time. No Function.toString() anywhere in the codebase. No regex strip. The bundler treats it as opaque data and can never touch it.
Here’s the cost: the script can’t reference imported helpers, so it’s a single ~100-line string with no type checking. We compensate with a dedicated test file (__tests__/script.test.ts) that compiles and executes the script in jsdom against every config permutation we care about.
The benefit: the entire class of “bundler ate my inline script” bugs is structurally impossible.
Bug #3: The XSS surface every theme library leaves open
This one made me sit up. I’d missed it on my first read.
Every theme library serializes user-controlled config into the inline script. The themeColor prop, the value map, the forcedTheme prop — any of these can be passed by your app code and end up inside the <script> tag as a JSON-stringified value.
Standard practice is JSON.stringify. But JSON.stringify does not escape sequences that would prematurely close an inline script tag. So if your app does this:
<ThemeProvider themeColor={{ light: userInputColor }}>
…and userInputColor somehow contains </script><script>alert(1)</script>, then the inline script serializes to:
<script>
!function(){var c={"tc":{"light":"</script><script>alert(1)</script>"},...};
</script>
The browser doesn’t know about JavaScript string semantics. It looks for </script> and terminates the tag on the first match. Everything after that becomes live HTML, including the injected <script>alert(1)</script> — and you’ve just shipped XSS via your theme prop.
@wrksz/themes has this surface open right now. Their core/script.ts:144-164 just JSON.stringifys each config value directly. There’s no escaping. I tested it — pass a malicious themeColor and it pops.
This is the same class of bug as upstream next-themes issue #213, which was reported in 2022 and stayed open. The reporter understood the problem; the fix never landed.
Ours fixes it in src/core/script.ts:92-109:
function safeJson(value: unknown): string {
return JSON.stringify(value)
.replace(/</g, '\u003c') // prevents </script> breakout
.replace(//g, '\u2028') // line separator: valid JSON, invalid JS
.replace(//g, '\u2029'); // paragraph separator: same
}
Three tiny escapes that prevent three real attack vectors:
-
<— every<becomes<, so</script>becomes</script>. The browser never sees a closing tag in the script body; the JS string content is unchanged. -
/— these are valid JSON whitespace but invalid in JS string literals. They’d crash the script at runtime. Escape them, and the runtime sees the same character it would have.
The escapes are semantically equivalent for legitimate input (the string the runtime produces is identical), so we lose nothing by being defensive.
We have a test for it. From __tests__/script.test.ts:240-258:
it('escapes </script> in user-supplied config (XSS via #213 surface)', () => {
const evil = '</script><script>window.__pwn=1;</script>';
const s = buildScript({
forcedTheme: evil,
themes: ['light', 'dark', evil],
themeColor: { light: evil, dark: '#000' },
});
expect(s).not.toContain('</script>');
expect(s).toContain('\u003c/script>');
runScript(s);
expect((window as { __pwn?: unknown }).__pwn).toBeUndefined();
});
If you’re shipping a theme library — any theme library that inlines user-controllable config into a <script> tag — go check your code right now. This bug is invisible until someone exploits it.
Bug #4: The disable-transition <style> that re-flickers on every set
This is the subtle one. Watch closely.
A theme library that swaps class="light" for class="dark" on <html> will trigger every CSS transition that touches a color, background, border, shadow — anything. If the user has transition: background-color 200ms on <body>, the whole page does a 200ms fade between themes. That’s usually unwanted on the initial swap (people want the theme to just snap into place) but desired afterward (so the toggle feels smooth).
The pattern every library uses to suppress this is the same:
- Inject
<style>*{ transition: none !important; }</style>into<head>. - Swap the class.
- Wait for the browser to flush the paint (
requestAnimationFrame(() => requestAnimationFrame(...))). - Remove the style.
Look at how @wrksz/themes does it, in packages/themes/src/providers/client-provider.tsx:142-153:
if (disableTransitionOnChange) {
const transitionValue =
typeof disableTransitionOnChange === "string"
? disableTransitionOnChange
: "none";
const style = document.createElement("style");
style.textContent = `*,*::before,*::after{transition:${transitionValue}!important}`;
document.head.appendChild(style);
requestAnimationFrame(() =>
requestAnimationFrame(() => document.head.removeChild(style)),
);
}
This runs in applyToDom, which is called from a useEffect that depends on the resolved theme. Every time the resolved theme changes — or every time React re-runs the effect for any other reason — it injects the style, paints, removes it.
The bug: it runs even when the theme didn’t actually change. If you call setTheme('dark') when the theme is already 'dark', the effect re-fires, a <style> tag gets injected and removed, the browser does a layout flush, and the page flickers for one paint frame. Worst case: your component re-renders during a route transition while theme === 'dark', the effect fires, the user sees a 16ms flash for no reason.
This is the kind of bug that’s invisible in dev (16ms is below the threshold of human perception) but accumulates. Across a session, your users feel a slightly less stable UI without being able to point at what.
Ours handles it in src/core/store.ts:147-183. The relevant lines:
const applied =
value && value[next.resolvedTheme] != null ? value[next.resolvedTheme] : next.resolvedTheme;
// Compare against current DOM state so a no-op apply does not inject
// the disable-transition <style> needlessly. This is the silent flicker
// source — every re-apply was inserting + removing a style tag, even
// when the resolved theme had not changed.
const domUnchanged =
previousApplied === applied && isAttributeAlreadyApplied(el, attribute, applied);
let restore: (() => void) | null = null;
if (!skipTransitionDisable && disableTransitionOnChange && !domUnchanged) {
restore = disableTransition(...);
}
if (!domUnchanged) {
applyAttribute(...);
}
Two checks: did the resolved theme produce the same DOM string as last time we applied? And does the DOM already match that string? If both, no-op. Don’t inject the style, don’t write to the DOM, don’t trigger anything.
We have a test that pins this behavior in __tests__/store-transition.test.ts:
it('does not inject a disable-transition <style> when setTheme is a no-op', () => {
const store = createStore({ ...defaults(), disableTransitionOnChange: true });
store.mount();
store.setTheme('dark');
const beforeCount = document.head.querySelectorAll('style').length;
store.setTheme('dark'); // identical theme
store.setTheme('dark'); // and again
expect(document.head.querySelectorAll('style').length).toBe(beforeCount);
store.unmount();
});
If we ever regress, the test catches it.
Bug #5: The god-component provider
This isn’t a runtime bug. It’s an architectural one that becomes a maintenance bug.
@wrksz/themes has a 332-line ClientThemeProvider component (packages/themes/src/providers/client-provider.tsx). Inside that one component:
- DOM apply logic (attribute setting, class toggling, color-scheme style)
- All five storage backends (cookie / localStorage / sessionStorage / hybrid / none reads + writes)
- System preference (
prefers-color-scheme) subscription -
storageevent handler (for cross-tab sync) -
pageshowevent handler (for bfcache restore) -
popstateevent handler (for back/forward navigation) - Cookie serialization
- Meta
theme-colorelement creation and update -
disableTransitionOnChangestyle injection
All wrapped in useEffect hooks, all conditional on overlapping deps, all in render-phase code where you can’t unit-test any piece in isolation.
Their __tests__/client-provider.test.tsx is 622 lines because every behavior needs a full React render setup. Want to test that cookies are read on mount? Render the provider. Want to test that the storage event fires? Render the provider. Want to verify the theme-color meta tag updates? Render the provider.
The core store is 59 lines (packages/themes/src/core/store.ts):
export function createThemeStore(): ThemeStore {
let state: ThemeState = { theme: undefined, systemTheme: undefined };
const listeners = new Set<() => void>();
function emit(): void { for (const listener of listeners) listener(); }
function setState(nextState: ThemeState): void { ... }
return { subscribe, getSnapshot, getServerSnapshot, setState, setTheme, setSystemTheme };
}
That’s the entire store. Just a state holder. All the actual work happens in the provider.
Ours splits it differently. The store (src/core/store.ts) is 362 lines and owns:
- State and lifecycle (
mount()/unmount()are public methods) - DOM apply with no-op short-circuit
- View Transitions API integration
- System + storage + pageshow subscriptions
- Runtime-mutable props via
update()
The provider component (src/providers/client.tsx, 142 lines) is essentially:
export function ClientThemeProvider(props) {
const storeRef = useRef(/* ... */);
if (!storeRef.current) storeRef.current = createStore(buildOptions(props));
const store = storeRef.current;
const state = useSyncExternalStore(store.subscribe, store.getState, getServerState);
useEffect(() => { store.mount(); return () => store.unmount(); }, []);
useEffect(() => { store.update(updatableProps(props)); }, [/* mutable props */]);
return <Context.Provider value={...}>{children}</Context.Provider>;
}
The component is presentational. All the actual logic lives in the store, which is plain TypeScript and can be unit-tested without React.
Our test files reflect this. We have separate test files for store.test.ts, dom.test.ts, resolve.test.ts, view-transition.test.ts, adapters.test.ts, script.test.ts, etc. Each one covers a single concern. The provider test is small because the provider is small.
This isn’t theory — it’s why we caught the no-op transition bug in the first place. The store’s apply function is callable in isolation, so we could write a direct test. In a god-component design, the same test requires a full React render, two effect cycles, and you might never notice the bug because it’s hiding under the noise of normal rendering.
What we built that the others don’t have
So far this post has been about bugs. Let me close with what’s possible when you have a clean architecture.
Storage adapters as a public interface
In @wrksz/themes, storage is a closed string union: "localStorage" | "sessionStorage" | "cookie" | "hybrid" | "none". Want to read the theme from a Redis-backed cookie service? An OS API in Tauri? An IndexedDB store? You can’t — fork the library.
Ours exposes the StorageAdapter interface directly:
export interface StorageAdapter {
get(): string | null;
set(value: string): void;
subscribe?(listener: (value: string | null) => void): () => void;
}
// app/my-adapter.ts
export const tauriAdapter: AdapterFactory = () => ({
get: () => window.__TAURI__.invoke('get_theme'),
set: (v) => window.__TAURI__.invoke('set_theme', { theme: v }),
});
Pass it via storage="custom" and a customStorage adapter prop. The store doesn’t care where the bytes come from.
View Transitions API integration
document.startViewTransition() is the browser’s built-in animation API for cross-state DOM changes. It’s perfect for theme swaps. None of the other libraries support it.
Ours has it in src/core/view-transition.ts. Two modes:
-
fade— a 200ms cross-fade between the old and new themes. -
circular— a circular reveal originating from the click coordinates. The library tracks the user’s last pointer event so the animation starts from the toggle button.
<ThemeProvider transition="circular">
<App />
</ThemeProvider>
// or per-call:
setTheme('dark', { transition: 'circular' });
Respects prefers-reduced-motion. Falls back to a direct apply if the browser doesn’t support the API.
Sec-CH-Prefers-Color-Scheme client hint
First-time visitors with no cookie still get a FOUC unless you can guess their preference server-side. Modern browsers will send a Sec-CH-Prefers-Color-Scheme: light|dark header on every request — but only after the server asks for it with Accept-CH.
We expose both halves:
// middleware.ts
import { acceptClientHintsHeader } from '@teispace/next-themes/server';
export function middleware() {
const res = NextResponse.next();
res.headers.set('Accept-CH', acceptClientHintsHeader());
return res;
}
After the first navigation, subsequent requests include the hint, and getTheme() picks it up automatically. Zero-flash SSR even on first visit. None of the other libraries support this.
Sync getTheme(Request) for middleware
We shipped this last week, in v0.5.0. @wrksz/themes has had it; we did not. Now we both do:
// middleware.ts
import { getTheme } from '@teispace/next-themes/server';
export function middleware(request: NextRequest) {
const theme = getTheme(request, { defaultTheme: 'dark' });
// sync — no await, no next/headers
}
The dispatch is structural — it recognizes a Request by the combination of a Headers instance and a string url, so polyfilled and framework-specific Request subclasses work too.
Codemod from next-themes
If you’re already on next-themes and want to migrate:
npx jscodeshift -t node_modules/@teispace/next-themes/codemod/from-next-themes.cjs src/
One command, all your imports rewritten. We use jscodeshift directly so the codemod ships as a .cjs file — no extra runtime, no setup.
Published with SLSA provenance
Every release of @teispace/next-themes is built and signed in GitHub Actions with npm provenance. The npm page shows a “Built and signed on GitHub Actions” badge linking back to the exact workflow run that built the tarball.
You can verify it yourself:
npm view @teispace/next-themes dist.attestations
# { url: '...', provenance: { predicateType: 'https://slsa.dev/provenance/v1' } }
next-themes and @wrksz/themes don’t sign their releases yet. If you care about supply chain security — and you should — this is a real differentiator.
A fair tally
next-themes |
@wrksz/themes |
@teispace/next-themes |
|
|---|---|---|---|
| React 19 inline script warning | ❌ | ✅ | ✅ |
__name bundler-stripping bug |
❌ | ✅ (regex workaround) | ✅ (raw string, structurally immune) |
| XSS escaping in inline script | ❌ | ❌ | ✅ |
No-op transition <style> injection |
❌ | ❌ | ✅ |
| Per-instance store (nested providers) | ❌ | ✅ | ✅ |
| Hybrid cookie + localStorage storage | ❌ | ✅ | ✅ |
sessionStorage storage |
❌ | ✅ | ✅ |
Sec-CH-Prefers-Color-Scheme hint |
❌ | ❌ | ✅ |
| View Transitions API | ❌ | ❌ | ✅ |
| Pluggable storage adapters | ❌ | ❌ | ✅ |
| Tailwind v4 preset | ❌ | ❌ | ✅ |
Codemod from next-themes
|
— | ❌ | ✅ |
Sync getTheme(Request) overload |
❌ | ✅ | ✅ |
createThemes() typed factory |
❌ | ✅ | ✅ |
useServerInsertedHTML script injection |
❌ | ✅ | ✅ |
| Published with SLSA provenance | ❌ | ❌ | ✅ |
I tried to be honest where the others are ahead. @wrksz/themes ships a tighter bundle (77 KB vs our 242 KB) because they don’t have view transitions, scoped themes, themed icons, the codemod, or the Tailwind preset. If you don’t want those features, you save bytes. The runtime cost is roughly equivalent though — both libraries are tree-shakable, and importing only the parts you use gives you a similar working set.
They also have ~3× our weekly download count, which is real. Adoption matters; popular libraries get more eyes on bugs. We’re new (April 2026) and they’re slightly less new (March 2026); both of us are dwarfed by next-themes.
What I’d want you to take from this
If you’re shipping anything that injects a <script> tag with user-controllable config, check your XSS escaping today. JSON.stringify is not enough. <, ,. Three escapes, ten seconds to add, prevents real exploits.
If you’re using next-themes on React 19, you have at least three of the bugs in this post. Migrate. Either to ours or to @wrksz/themes — both are better than what you have.
If you’re building a similar library, separate the store from the provider. The 332-line god component will catch up with you in six months. The 60-line store will not.
If you’re choosing between ours and @wrksz/themes: read both READMEs, check both repos, pick the one whose architecture you trust more after looking at the code. I’m biased, obviously. But the answer is in the source, not in the marketing.
Try it
npm install @teispace/next-themes
# or
yarn add @teispace/next-themes
# or
pnpm add @teispace/next-themes
Repo: github.com/teispace/npm-packages/tree/main/packages/next-themes
npm: npmjs.com/package/@teispace/next-themes
Issues, PRs, and counter-examples welcome. If I got something wrong about @wrksz/themes or next-themes, file an issue and I’ll correct it.
