Skip to content

Theming

The comment widget is meant to look like part of your site rather than like something bolted onto the end of it. Charcha gets there by owning as little of the appearance as it can, and by handing you the rest: plain elements, stable class names, and a mode where it sends no CSS at all.

Two lines. The <script> names your deployment once, and the widget takes the address from there.

<div id="charcha"></div>
<script src="https://your-worker.example.workers.dev/embed.js" defer></script>
Attribute On What it does
data-styles the mount element inherit (default), tokens or bare. See below
data-thread the mount element Pins the conversation to a slug of your choosing instead of the page’s path. Use it when the same conversation appears at more than one address, or when your CDN rewrites paths
data-api the mount element The deployment address, if it is not where embed.js was served from
data-charcha any element An alternative to id="charcha", for pages that mount more than one widget
data-turnstile-sitekey the mount element Your Turnstile sitekey. Set it only if you also set TURNSTILE_SECRET_KEY on the Worker. See below

embed.js is served as a static asset, so fetching it costs your deployment nothing against the Cloudflare request budget. Only the comment read does. The file is 8,242 bytes gzipped, against a ceiling of 10,240.

Before any of this renders, your deployment has to be told that your site is allowed to use it, or the browser refuses every response. Getting started has that step.

Turnstile is optional and starts off. With no TURNSTILE_SECRET_KEY on the Worker, Charcha never checks a token and never makes the call. With no data-turnstile-sitekey on the page, Cloudflare’s script is never fetched. Nothing loads speculatively.

Set both or neither. They are two halves of one switch. Setting only the secret stops every comment reaching the page, because the Worker is waiting for a token that nothing on the page produces. Those comments are held for review rather than refused, until one real token has verified on the deployment, after which a comment with no token is refused outright. So a missing sitekey shows up as a moderation queue filling with comments that look perfectly ordinary, which is why your dashboard’s Setup tab names the sitekey by name. The sitekey itself is public by design, and appears in the page HTML of every site that uses Turnstile, so putting it in an attribute discloses nothing.

The Worker’s half is not collected by the deploy form. You add it afterwards, from the Cloudflare dashboard or from a terminal if you prefer. Setting a secret has both routes.

Once it is on, Charcha renders the widget inside its own composer in interaction-only mode. Most readers never see it, and a reader who does get challenged sees it appear directly above the Post button. Tokens are valid for 300 seconds and can be used once, so the widget refreshes before a comment is posted and again after. A form left open for an hour still posts.

Charcha itself stores nothing in the reader’s browser, with this on or off. No cookie, no localStorage, no sessionStorage. That is a claim about Charcha, and Charcha is the only part of the arrangement it can be made about: Cloudflare’s widget is a third-party script in an iframe on challenges.cloudflare.com, and what it does in there is covered by the Turnstile Privacy Addendum, which is worth reading before you switch this on.

One part of that is worth naming, because it would land on your domain rather than Cloudflare’s. Turnstile’s pre-clearance issues a cf_clearance cookie. Every widget has it off by default, and turning it on is something you would do yourself in the Cloudflare dashboard. Charcha’s no-cookies promise cannot cover a cookie you asked Cloudflare to set. Leave it off, or disclose it.

This is the whole vocabulary. No shadow DOM and no iframe, just ordinary elements in your page, which is what lets your own stylesheet reach them.

<ol class="charcha-comments">
<li class="charcha-comment" id="charcha-comment-1">
<div class="charcha-comment-header">
<span class="charcha-comment-author">Rahul</span>
<time class="charcha-comment-time" datetime="2026-07-23T19:46:40.000Z">
2026-07-23 19:46 UTC
</time>
</div>
<div class="charcha-comment-body"><p>The part people underestimate is the export.</p></div>
<ol class="charcha-replies">
<li class="charcha-comment charcha-reply" id="charcha-comment-2">
<div class="charcha-comment-header">
<span class="charcha-comment-author">Maya</span>
<time class="charcha-comment-time" datetime="2026-07-23T20:02:11.000Z">
2026-07-23 20:02 UTC
</time>
</div>
<div class="charcha-comment-body"><p>Agreed, and it is the part nobody tests.</p></div>
</li>
</ol>
</li>
</ol>

A page with no comments yet renders one element instead:

<p class="charcha-empty">Be the first to comment</p>

Two lists, because two things emit markup. The renderer produces the comments themselves, and the embed produces the widget around them.

Treat both lists as an API you are allowed to depend on. Your stylesheet targets these names directly, so renaming one would quietly break the appearance of every site that had styled it. Charcha will not do that to you outside a breaking change.

Class Where
charcha-comments The outer <ol> holding every top-level comment
charcha-comment One comment. On every comment, including replies
charcha-reply Added alongside charcha-comment on a reply
charcha-comment-by-owner Added when the comment was written by the site owner
charcha-comment-header The author-and-time row
charcha-comment-author The author’s name
charcha-comment-time The timestamp, a real <time> with a machine-readable datetime
charcha-comment-body The rendered comment text
charcha-replies The nested <ol> holding one comment’s replies
charcha-empty Shown instead of the list when there are no comments
charcha-truncated A notice below the list when the page holds more comments than one read returns
Class Where
charcha-root The widget’s outer element. box-sizing: border-box is set on it and everything inside
charcha-status A live region: loading, a read failure, or the confirmation after you post
charcha-retry The button offered when the comments could not be loaded
charcha-thread The element the rendered comments are placed into
charcha-comment-actions The row holding one comment’s Reply button
charcha-reply-button Reply. Only on top-level comments, since there is no third level
charcha-pending The badge on your own just-posted comment, awaiting review
charcha-form The composer
charcha-form-replying Added to the composer while it is mounted under a comment as a reply
charcha-reply-header The “Replying to …” row inside the composer
charcha-reply-to The “Replying to …” text
charcha-cancel-reply The button that dismisses the reply
charcha-tabs The Write/Preview tablist, a real role="tablist"
charcha-tab One tab. The selected one carries aria-selected="true". Style that, there is no separate class
charcha-write The Write panel: the toolbar and the comment field
charcha-toolbar The formatting toolbar, a real role="toolbar"
charcha-toolbar-button One formatting button
charcha-fields The name-and-email row
charcha-field One labelled field
charcha-label A field’s <label>
charcha-input The name and email <input>s
charcha-textarea The comment <textarea>
charcha-preview The Preview panel. Holds a charcha-comment-body once the Worker has rendered the draft
charcha-hint The line under the email field
charcha-error The message shown when a comment was not accepted
charcha-actions The row holding the submit button
charcha-submit Post comment

A few things in there will save you a surprise:

  • charcha-comment is on replies too. A reply carries both charcha-comment and charcha-reply. Style the common case once, then use .charcha-reply for what differs. For top-level comments only, .charcha-comments > .charcha-comment.
  • Threading stops at one level. There is no third level to style. The database refuses to create one.
  • The preview is the published comment. Its contents are wrapped in charcha-comment-body, the renderer’s own class, the same one a posted comment carries. Anything you style there turns up in the preview without a second rule, because the Worker renders the preview with the function that renders the page.
  • Each comment has a stable id of the form charcha-comment-<id>, so links to individual comments work.
  • The timestamp is UTC, and the visible text says so. The datetime attribute carries full precision.

Since the markup is plain elements in your page, your own stylesheet reaches it with no special mechanism:

.charcha-comment {
padding-block: 1rem;
border-top: 1px solid color-mix(in oklab, currentColor 16%, transparent);
}
.charcha-comment-author { font-weight: 600; }
.charcha-comment-time {
font-size: 0.875em;
opacity: 0.7;
}
.charcha-replies {
margin-inline-start: 1.5rem;
list-style: none;
}
.charcha-comment-by-owner {
/* mark the site owner's own replies */
}

One trick there is worth stealing: derive colours from currentColor instead of naming them. color-mix(in oklab, currentColor 16%, transparent) gives you a border that is legible on a light background and on a dark one, with no media query and no knowledge of the page around it. Charcha’s own default stylesheet works the same way, for the same reason.

Three of them, chosen with an attribute on the mount element, so the setting lives in your site’s repository rather than only in a database:

<div id="charcha" data-styles="bare"></div>
Mode What Charcha sends Who owns the appearance
inherit (default) A small default stylesheet Charcha, deriving everything from your page
tokens The default stylesheet, with thirteen properties open to you You, through those properties
bare No CSS at all You, entirely

inherit is deliberately not overridable. In that mode Charcha owns the appearance, and the way to change something is to ask for tokens. That is what makes the choice between the two mean something instead of being two names for one stylesheet.

The default stylesheet names no colour and no typeface. Every surface, rule and muted label is mixed out of your page’s own currentColor, and every size is relative to your own font:

--cc-muted: color-mix(in oklab, currentColor 70%, transparent);
--cc-line: color-mix(in oklab, currentColor 20%, transparent);
--cc-control-line: color-mix(in oklab, currentColor 55%, transparent);
--cc-surface: color-mix(in oklab, currentColor 5%, transparent);

Dark mode falls out of that. There is no widget theme to switch, no media query, and nothing stored in the reader’s browser. The widget is dark exactly when your page is.

bare mode is the reason the class names above are an API rather than an internal detail.

In tokens mode, set any of these anywhere they will be inherited, at :root or on the element around the widget. Setting none of them is identical to inherit.

Property Default What it is
--charcha-muted currentColor at 70% Timestamps, hints, status text
--charcha-line currentColor at 20% Rules between comments, and beside a quote
--charcha-control-line currentColor at 55% The border of every input and button
--charcha-surface currentColor at 5% Button and code-block fills
--charcha-surface-strong currentColor at 11% The same, on hover
--charcha-radius 0.5em Corner radius
--charcha-gap 1.25em The vertical rhythm between comments
--charcha-pad 0.75em Inner padding
--charcha-indent 1.25em How far a reply is indented
--charcha-font-size 1em The widget’s base size, relative to yours
--charcha-line-height 1.55
--charcha-focus currentColor The focus ring
--charcha-focus-width 2px

--charcha-line and --charcha-control-line are two properties on purpose. A rule between comments is decoration and looks right when it is faint. The border of a text input is what tells a reader the control is there at all, and WCAG wants it at 3:1 against the background. One property cannot do both jobs without failing one of them.

Charcha targets WCAG 2.1 AA and the widget is built to it: a real <form> with real <label>s, a real role="toolbar" you can drive with the arrow keys, focus that moves into the reply composer when you open it and onto your comment after you post it, and errors tied to their field rather than signalled by colour. No state in the widget is carried by colour alone. That is a WCAG requirement, and it is also the only approach that can work when the palette belongs to somebody else.

Measured in a browser against a light host (#1f1d1b on #fdfcfa) and a dark one (#e6e8ea on #0f1216), with the same stylesheet and nothing telling it which was which:

light dark needs
Comment text 16.4:1 15.3:1 4.5:1
Timestamps, hints, status 6.2:1 7.9:1 4.5:1
Input and button borders 3.7:1 5.2:1 3:1

Two parts of this are yours rather than Charcha’s, because they depend on the page around the widget:

  • Your own contrast, if it is marginal. Everything above is mixed from your text colour, so it is as legible as your body text is, and mixing toward transparent can only reduce contrast. If your own text sits near the 4.5:1 floor, no ratio Charcha picks will keep the muted labels above it. tokens mode is the answer there.
  • Heading order. Charcha emits no headings. If you put a “Comments” heading above the widget, give it the level that fits your page’s outline.

If your site sets a Content-Security-Policy

Section titled “If your site sets a Content-Security-Policy”

The default stylesheet is written into the page as a <style> element, so a policy with style-src 'self' and no 'unsafe-inline' blocks it. The widget still works. You get the markup with no CSS, which is bare mode by accident, so either allow the style or choose bare deliberately and ship your own. You will also need connect-src to cover your Charcha deployment, and script-src to allow it to serve embed.js.