DOM
dom.service.ts is a function module: standalone use* helpers with no instance to create. Two design rules apply to every function on this page:
- SSR-safe by default — outside a browser (
typeof document === "undefined") queries returnnull/[], boolean helpers returnfalse, and setters are no-ops. The only exception isuseCreateElement(), which throws because a detached element has no server meaning. - Selectors or elements — every helper that targets an element accepts either a CSS selector string (
"#submit",".card") or anElementreference. Pass references when you already have them; pass selectors for terse event-delegation code paths.
The setters are also hardened: useSetAttribute() blocks on* handlers, javascript:/vbscript: URLs and unsafe data: URIs, while useSetText() is the safe way to render user content. See Attributes and Content for details.
Quick example
import {
useQuerySelector, useQuerySelectorAll, useOn, useToggleClass,
useSetText, useGetRoot,
} from "katanakit-js";
const root = useGetRoot();
const button = useQuerySelector<HTMLButtonElement>("#menu-toggle");
const items = useQuerySelectorAll<HTMLLIElement>("nav .item");
// Toggle a class on <html> and update an accessible label
const off = useOn(button, "click", () => {
const open = useToggleClass(root!, "menu-open");
button?.setAttribute("aria-expanded", String(open ?? false));
});
items.forEach((item) => useSetText(item, item.textContent?.toUpperCase() ?? ""));
// Cleanup when the component unmounts
off?.();Browser & root references
useIsBrowser()
Returns true only when both window and document exist. Use it to gate code that cannot be expressed through the other helpers — for example, deciding whether to attach a third-party library that touches window.
import { useIsBrowser } from "katanakit-js";
if (useIsBrowser()) {
const { default: confetti } = await import("canvas-confetti");
confetti({ particleCount: 80, spread: 60 });
}Real use case — progressive enhancement: a marketing page can ship a canvas animation that only loads in the browser, while the same module stays importable from a server component that pre-renders the static fallback.
useGetRoot()
Returns document.documentElement (the <html> element) or null on the server. This is the canonical hook for theme classes, lang attributes and anything that must be set at the document level.
import { useGetRoot, useAddClass, useRemoveClass } from "katanakit-js";
const root = useGetRoot();
export function setDarkMode(enabled: boolean) {
if (!root) return;
useToggleClass(root, "dark", enabled);
}useGetBody()
Returns document.body cast as HTMLBodyElement, or null on the server. Use it for scroll locking, page-level classes and global overlays.
import { useGetBody, useAddClass, useRemoveClass } from "katanakit-js";
const body = useGetBody();
export function lockScroll(locked: boolean) {
if (!body) return;
if (locked) useAddClass(body, "overflow-hidden");
else useRemoveClass(body, "overflow-hidden");
}Real use case — a modal component locks body scroll while open; because useGetBody() returns null during SSR, the same component renders on the server without branching on typeof window.
Querying elements
useQuerySelector(selector)
Typed wrapper around document.querySelector. The overloads infer the element type from the tag name (useQuerySelector("input") → HTMLInputElement | null) or from an explicit type argument for selectors.
import { useQuerySelector } from "katanakit-js";
const form = useQuerySelector("form.checkout"); // HTMLFormElement | null
const email = useQuerySelector<HTMLInputElement>("#email"); // HTMLInputElement | null
const firstRow = useQuerySelector<HTMLTableRowElement>("tbody tr");Returns null when the element does not exist or when called outside the browser, so always guard with ?. or an early return.
Real use case — form validation: grab the field once per submit and reuse the reference for setCustomValidity():
const email = useQuerySelector<HTMLInputElement>("#email");
function validateEmail() {
if (!email) return true;
const valid = /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email.value);
email.setCustomValidity(valid ? "" : "Enter a valid email");
return valid;
}useQuerySelectorAll(selector)
Typed querySelectorAll that returns a real array (Element[], never a NodeList), so .map(), .filter() and for...of work without Array.from(). Returns [] outside the browser.
import { useQuerySelectorAll } from "katanakit-js";
const rows = useQuerySelectorAll<HTMLTableRowElement>("tbody tr");
const emails = rows.map((row) => row.dataset.email).filter(Boolean);Real use case — bulk row selection in a data table: toggle a checkbox in every row, then read the selection in one pass.
const checkboxes = useQuerySelectorAll<HTMLInputElement>("tbody input[type=checkbox]");
export function selectAll(checked: boolean) {
checkboxes.forEach((box) => (box.checked = checked));
}useGetElementById(id)
Shortcut for document.getElementById, with an optional type parameter. Unlike useQuerySelector, the id is passed without #.
import { useGetElementById } from "katanakit-js";
const dialog = useGetElementById<HTMLDialogElement>("confirm-dialog");Real use case — imperative UI (dialogs, popovers, video players): keep a stable id in the markup and grab the element when the action runs, avoiding global state just to hold a reference.
useGetElementByClass(className)
Finds the first element with a class, accepting "card" or ".card" — handy when class names arrive from configuration or CMS data.
import { useGetElementByClass } from "katanakit-js";
const hero = useGetElementByClass("hero"); // `.hero` added internally
const card = useGetElementByClass<HTMLElement>(".card");Classes
useAddClass(target, className)
Adds a class to the resolved element; silent no-op when the element or the DOM is missing. Idempotent: adding twice does nothing.
import { useAddClass } from "katanakit-js";
useAddClass("#header", "sticky");
useAddClass(myElement, "visible");useRemoveClass(target, className)
Removes one class or an array of classes in a single call — useful when cleanup depends on several mutually exclusive states.
import { useRemoveClass } from "katanakit-js";
useRemoveClass("#header", "sticky");
useRemoveClass(myElement, ["visible", "active", "loading"]);useToggleClass(target, className, force?)
Toggles a class and returns whether it is now present (boolean | undefined). Pass force to make the operation deterministic — force: true adds, false removes — which avoids read-then-write races in event handlers.
import { useToggleClass } from "katanakit-js";
const isOpen = useToggleClass(".sidebar", "open");
useToggleClass(".sidebar", "hidden", false); // guaranteed closedReal use case — accordions and drawers: keep the source of truth in the DOM class and mirror it to aria-expanded, so CSS and accessibility never drift:
const trigger = "#faq-1-trigger";
const panel = "#faq-1-panel";
useOn(trigger, "click", () => {
const open = useToggleClass(panel, "is-open") ?? false;
useSetAttribute(trigger, "aria-expanded", String(open));
});useHasClass(target, className)
Returns false (never throws) when the element is missing — safe to call in render paths and SSR.
import { useHasClass } from "katanakit-js";
if (useHasClass(".btn-submit", "disabled")) {
showTooltip("Complete the form first");
}Attributes & data
useGetAttribute(target, attr)
Returns the attribute value or null when the attribute or element is absent.
import { useGetAttribute } from "katanakit-js";
const href = useGetAttribute("a.cta", "href");
const alt = useGetAttribute<HTMLImageElement>("img.cover", "alt");useSetAttribute(target, attr, value)
Sets an attribute with XSS protection. The function throws when:
- the attribute is an event handler (
onclick,onerror, …) — useuseOn()instead; - the value of a URL-like attribute (
href,src,action,formaction,xlink:href) uses ajavascript:orvbscript:scheme; - a
data:URI is not a raster image (png,jpg,gif,webp); - the attribute is
srcdoc— sanitize HTML with a dedicated library instead.
Control characters are stripped before the scheme check, so obfuscations like java\nscript: are rejected too.
import { useSetAttribute } from "katanakit-js";
useSetAttribute("img.hero", "src", "/photo.jpg");
useSetAttribute("a.link", "href", "https://example.com");
useSetAttribute("button.next", "aria-disabled", "true");
// Throws — use useOn() for events
// useSetAttribute("button", "onclick", "doSomething()");Real use case — link previews built from API data: you can safely write href and src from fetched content, because protocol-relative attacks and script URLs are rejected at the boundary:
const { data } = await useGet<Card>("cms", "cardById", { params: { id } });
if (data) {
useSetAttribute("#card-link", "href", data.url);
useSetAttribute("#card-image", "src", data.image);
}useRemoveAttribute(target, attr)
Removes an attribute; no-op when the element is missing. Common for enabling controls (disabled), clearing validation hints, or resetting ARIA state.
import { useRemoveAttribute } from "katanakit-js";
useRemoveAttribute("#submit", "disabled");useGetDataAttribute(target, key)
Reads data-* values through dataset, so the key is camelCase (data-user-id → "userId"). Returns undefined when absent.
import { useGetDataAttribute } from "katanakit-js";
const userId = useGetDataAttribute("tr.row", "userId");useSetDataAttribute(target, key, value)
Writes data-* values through dataset; the DOM reflects the kebab-case attribute (key: "userId" → data-user-id).
import { useSetDataAttribute } from "katanakit-js";
useSetDataAttribute("tr.row", "status", "active");Real use case — list-to-detail navigation without a router state library: stamp each row with the entity id at render time and read it in the click handler.
useQuerySelectorAll<HTMLTableRowElement>("tbody tr").forEach((row) => {
useSetDataAttribute(row, "recordId", row.id);
useOn(row, "dblclick", () => openRecord(useGetDataAttribute(row, "recordId")));
});Events
useOn(target, event, callback, options?)
Attaches a listener and returns an unsubscribe function (() => void), or null outside the browser or when the selector matches nothing. target can be any EventTarget (element, window, document, a WebSocket, …) or a CSS selector. The callback is typed from HTMLElementEventMap, and options is forwarded to addEventListener (capture, once, passive, …).
import { useOn } from "katanakit-js";
const offScroll = useOn(window, "scroll", () => {
document.body.classList.toggle("scrolled", window.scrollY > 24);
}, { passive: true });
const offClick = useOn("#cta", "click", (event) => {
event.preventDefault();
track("cta-click");
});
// Later — during teardown
offScroll?.();
offClick?.();Real use case — a sticky header that reacts to scroll: the listener is passive (no scroll jank), and the unsubscribe function makes cleanup explicit. In component frameworks this pairs directly with lifecycle hooks:
// Vue
onMounted(() => { off = useOn(window, "resize", onResize); });
onBeforeUnmount(() => off?.());
// React
useEffect(() => useOn(window, "resize", onResize) ?? undefined, []);Content
useCreateElement(tagName, options?)
Creates a detached element with typed tag inference. Throws outside the browser — this is the one helper that cannot degrade to a no-op, because the return value is always used. The optional options argument is the standard ElementCreationOptions (customized built-ins).
import { useCreateElement, useSetText, useAppend } from "katanakit-js";
const badge = useCreateElement("span");
useSetText(badge, "New");
useAddClass(badge, "badge");
useAppend("#product-name", badge);useSetText(target, text)
Sets textContent. This is the safe sink for user-generated content — no HTML parsing happens, so <script> in the string is displayed literally.
import { useSetText } from "katanakit-js";
useSetText("#greeting", `Hello, ${user.name}!`);Real use case — comments, chat messages and profile names: any value that originated from another user must go through useSetText, never useSetHtml.
useSetHtml(target, html)
Sets innerHTML. Powerful and equally dangerous: it is an HTML injection sink. Only pass trusted static markup; sanitize everything else with a dedicated library (for example DOMPurify) before calling.
import { useSetHtml } from "katanakit-js";
// Safe — developer-authored markup
useSetHtml("#banner", "<strong>Black Friday</strong> — 40% off");
// Never do this with user input
// useSetHtml("#comment", comment.body);useAppend(target, child)
Appends a child (element or selector) to a parent (element or selector). Missing parents or children are ignored, so it is safe for optional UI.
import { useCreateElement, useSetText, useAppend, useAddClass } from "katanakit-js";
function addTodo(text: string) {
const item = useCreateElement("li");
useSetText(item, text);
useAddClass(item, "todo-item");
useAppend("#todo-list", item);
}Real use case — an optimistic "pending" row while a mutation resolves: append the row immediately, then replace its contents or remove it when the server confirms — no virtual DOM required.
useRemove(target)
Removes the element from the DOM. No-op when missing.
import { useRemove } from "katanakit-js";
useRemove(".toast--dismissed");Modals
useModalClose(modalSelector, useRemoveDOM?)
Closes a modal in one call, with two strategies:
useRemoveDOM = false(default) toggles thehiddenclass, keeping the element in the DOM — the right choice when the modal may reopen and you want to preserve its state.useRemoveDOM = trueremoves the node entirely — useful for one-shot interstitials and modals created per session.
import { useModalClose, useOn, useQuerySelector } from "katanakit-js";
// Close button inside the dialog
useOn("#modal .close", "click", () => useModalClose("#modal"));
// Click on the backdrop — but not on the panel itself
useOn("#modal", "click", (event) => {
if (event.target === useQuerySelector("#modal")) useModalClose("#modal", true);
});Real use case — a promo modal that should never come back during the session: remove it from the DOM when dismissed, and the backdrop click handler above will not find it again.
Putting it together: a dismissible banner
import {
useQuerySelector, useAddClass, useRemove, useOn, useGetStorage, useSetStorage,
} from "katanakit-js";
const DISMISS_KEY = "promo:dismissed";
const banner = useQuerySelector("#promo-banner");
if (banner && !useGetStorage<boolean>(DISMISS_KEY)) {
useAddClass(banner, "is-visible");
}
useOn("#promo-close", "click", () => {
useRemove("#promo-banner");
useSetStorage(DISMISS_KEY, true);
});The banner composes three services (DOM here, Storage for persistence and useOn for wiring) and still renders safely on the server, because every call degrades or no-ops without a document.
Related
- Watch — reactive
MutationObserverwrapper for DOM changes. - Storage — persist UI state like dismissed banners.
- Theme — the themed cousin of
useGetRoot(). - API Reference — every export, generated from source.