@kobzi/gmconfig
v1.0.0
Published
Lightweight, multi-instance configuration dialog for Tampermonkey userscripts
Maintainers
Readme
gmConfig
A modern settings dialog for userscripts — define an object, get a themed panel.
TypeScript · native <dialog> · zero dependencies · light/dark · accessible · MIT
Quick start · Field types · Recipes · API · Full vs Lite · Migrating
Choose your size: Full ~16 kB min / ~6.5 kB gzip · Lite ~7.3 kB min / ~3.4 kB gzip
You write a plain object describing your settings. gmConfig renders a native <dialog>, persists the values, and hands them back — no iframe, no framework, no build step on your side (just drop in the IIFE). It leans on the browser instead of fighting it: native inputs, automatic light/dark, real focus-trapping, HTML5 validation.
const cfg = new gmConfig();
await cfg.init({
username: { type: "text", label: "Username", default: "", required: true },
interval: { type: "number", label: "Refresh (s)", default: 30, min: 5, max: 300 },
darkMode: { type: "checkbox", label: "Dark mode", default: false },
region: { type: "select", label: "Region", options: ["US", "EU", "Asia"], default: "EU" },
}, { title: "My Settings" });
const saved = await cfg.open(); // → true if saved, false if cancelled
console.log(cfg.get()); // → { username, interval, darkMode, region }Need the smallest possible bundle? A Lite build ships the same
gmConfigclass with a trimmed feature set — swap the@requireURL, change nothing in your code.
Highlights
- Native everything —
<dialog>,color-schemedark mode,accent-color, native pickers, HTML5 validation. Tiny CSS, no re-styling the browser. - Rich field set — every HTML5 input plus
select,radio,multicheck,file,color,range,button,hidden. - Custom field types — build entirely new controls with
render/get/set/destroy, the kind of extensibility you'd expect from a framework, not a userscript helper. - Structure — tabs, sections (collapsible), side-by-side rows, conditional fields (
depends/showWhen). - Live & safe —
onChange, computed descriptions, async validation, save/close guards, unsaved-changes warning, and edits that stay a preview until you hit Save. - CSP & Trusted Types safe — no
innerHTMLanywhere and a constructed stylesheet, so it renders on locked-down sites where iframe/HTML-string libraries break. - Two builds, one source — full, or a smaller lite subset compiled from the same code. Same class name and API shape, swap the URL.
Why another config library?
GM_config was designed around 2009 browser capabilities — so it ships its own iframe, focus management, widgets, and styling infrastructure. Modern browsers now provide most of that natively:
<dialog>— modal stacking, backdrop, focus-trapping, Esc-to-closecolor-scheme/accent-color— themed surfaces and tinted controls, no JS- native form controls + HTML5 validation — pickers, dropdowns, constraints for free
gmConfig keeps the familiar field-definition workflow (credit to GM_config by Mike Medley and contributors — a longtime staple of the ecosystem) but is not a fork: the implementation is rebuilt from scratch around these primitives. The result is a fraction of the code and CSS, with capabilities (custom controls, computed descriptions, async validation) that the original never had.
Install
Add one @require line. Pick the full build (everything) or the smaller lite build:
// ==UserScript==
// @name My Script
// @require https://cdn.jsdelivr.net/npm/@kobzi/gmconfig@latest/dist/gmConfig.iife.min.js
// @grant GM.getValue // persistence (full build falls back to localStorage if omitted)
// @grant GM.setValue
// @grant GM_registerMenuCommand // optional — only for the `menu` option
// ==/UserScript==Swap the @require for gmConfig.lite.iife.min.js to use the lite build. @latest is convenient for demos — pin a version (@kobzi/[email protected]) in production scripts.
Using a bundler? The npm package ships ESM + types:
import { gmConfig } from "@kobzi/gmconfig"; // full
import { gmConfig } from "@kobzi/gmconfig/lite"; // liteCSP: gmConfig injects its stylesheet as a constructed stylesheet (
adoptedStyleSheets), whichstyle-srcdoesn't govern — so it stays styled even on CSP-strict sites, with noGM_addStylegrant needed. (GrantGM_addStyleonly if you inject your own custom CSS with it.)
Persistence. The full build uses GM.getValue / GM.setValue, and falls back to localStorage (prefix gmcfg:) when GM storage is missing — e.g. @grant none or a bookmarklet. The fallback is per-origin, so settings aren't shared across domains. The lite build requires the GM storage functions (no fallback).
Quick start
The intro example at the top is the whole workflow: init(settings, options) loads persisted values, open() shows the dialog and resolves true on save / false on dismiss, get() / set() read and write values. One extra worth knowing from day one — add menu: true and gmConfig registers the Tampermonkey menu command for you (needs the GM_registerMenuCommand grant; pass a string for a custom label):
await cfg.init(settings, { title: "My Settings", menu: true }); // menu item "My Settings" → opens the dialogField types
Any HTML5 input type works directly — text, email, url, password, tel, search, number, range, date, time, datetime-local, month, week, color. On top of those (○ = full-build only):
| Type | Value | Notes |
|------|-------|-------|
| text / email / url / … | string | required, pattern, minLength, maxLength, placeholder |
| number / range | number | min, max, step |
| checkbox | boolean | |
| textarea | string | rows; ○ auto-grows with content (field-sizing, evergreen browsers) |
| select | string | options (○ placeholder row) |
| color | string | native swatch |
| file | your choice | accept; handle the file via onFile |
| button | — | click callback, not persisted |
| hidden | any | persisted, no UI |
| radio ○ | string | options — all choices shown at once |
| multicheck ○ | string[] | dropdown of checkboxes (default: []) |
| list ○ | object[] | repeatable records built from item — see Lists |
| custom ○ | any | your own control via render — see Custom controls |
options takes an array (["a", "b"]), a label map ({ a: "Label A" }), or a sync/async provider
(() => [...] / async () => [...]) — handy for choices fetched at open time. Text-like inputs
also accept datalist (a list or sync/async provider) for native autocomplete suggestions.
Field options
Keys marked ○ are full-build only.
| Key | Purpose |
|-----|---------|
| label, default | label text, initial value |
| title | hover tooltip |
| depends / showWhen | show conditionally — see Conditional fields |
| description | help text under the control (○ (values) => string live form is full-only) |
| disabled, readonly, save: false | non-editable, read-only, or excluded from storage |
| attrs | extra attributes spread onto the control — autocomplete, inputmode, spellcheck, anything HTML supports |
| section | group under a heading — a string (○ { title, desc?, collapsible?, collapsed? } form is full-only) |
| tab ○ | group into a tab (untabbed fields render above the bar) |
| row: "id" ○ | place consecutive same-id fields side by side (also works inside list items) |
| labelPos ○ | "left" / "right" — label beside the control; "bottom" — caption-style under it |
| datalist ○ | autocomplete suggestions for text inputs — a list or sync/async provider |
| item, addLabel, itemTitle, reorder, max ○ | list field config — see Lists |
| validate ○ | custom async/sync validation with inline errors |
The complete lite/full breakdown lives in Full vs Lite.
Recipes
Headings marked ○ use full-build only features.
Sections
The plain-string form works in Lite; the
{ title, desc, collapsible }object form is full-only.
name: { type: "text", label: "Name", default: "", section: "Profile" },
apiKey: { type: "password", label: "API key", default: "",
section: { title: "API", desc: "Stored locally", collapsible: true, collapsed: true } }, // ○ object formTabs & rows ○
name: { type: "text", label: "Name", default: "", tab: "Profile" },
theme: { type: "select", label: "Theme", options: ["system","light","dark"], default: "system", tab: "Appearance" },
start: { type: "time", label: "Start", default: "09:00", row: "shift" },
end: { type: "time", label: "End", default: "17:00", row: "shift" },Conditional fields
useProxy: { type: "checkbox", label: "Use proxy", default: false },
proxyUrl: { type: "url", label: "Proxy URL", default: "", showWhen: { useProxy: true } },
// or a predicate:
proxyUrl: { type: "url", label: "Proxy URL", default: "", depends: v => v.useProxy === true },Validation ○
Runs on Save, after native HTML5 constraints. Return an error string to block (shown inline), or nothing if valid:
username: { type: "text", label: "Username", default: "",
validate: v => /^[a-z0-9_]{3,}$/.test(v) ? undefined : "3+ chars, a-z 0-9 _ only" },
port: { type: "number", label: "Port", default: 8080,
validate: async v => (await isPortFree(v)) ? undefined : "Port in use" },Dynamic options
setFieldworks in both builds; this example usesmulticheck, which is full-only — swap inselectfor Lite.
await cfg.init({ tags: { type: "multicheck", label: "Tags", options: {}, default: [] } }, {
onOpen: async () => {
const tags = await fetch("/api/tags").then(r => r.json());
cfg.setField("tags", { options: Object.fromEntries(tags.map(t => [t.id, t.name])) });
},
});Or skip setField entirely and hand options a provider — select / radio / multicheck
fill themselves (and re-apply the stored value when an async provider resolves):
tags: { type: "multicheck", label: "Tags", default: [], options: () => fetch("/api/tags").then(r => r.json()) },Lists ○
type: "list" renders a repeatable array of records. Each item is a mini-form built from item
(a normal field map — including showWhen, row, datalist, even a nested list). The value is
an object[]; gmConfig handles add / remove / reorder, persistence, and revert.
endpoints: {
type: "list", label: "API endpoints", default: [], reorder: true, addLabel: "+ Endpoint",
itemTitle: (v, i) => v.name || `#${i + 1}`, // collapsed-row header
item: {
name: { type: "text", label: "Name", row: "r" },
method: { type: "select", label: "Method", options: ["GET", "POST"], default: "GET", row: "r" },
url: { type: "url", label: "URL" },
auth: { type: "checkbox", label: "Needs auth", default: false },
token: { type: "text", label: "Token", showWhen: { auth: true } }, // per-item conditional
},
},item field options supported inside a list: every native input, select / radio / multicheck,
datalist, showWhen / depends, row, custom controls (render / get / set / destroy —
cleanup hooks fire on item removal and dialog close), and nested list. (tab and section are
not applied inside items.) A max caps the item count — the add button disables at the limit.
File processing
A file field hands you the picked File and a setter — you decide what (if anything) to store:
importList: {
type: "file", label: "Import list", accept: ".txt",
onFile: (file, set) => file.text().then(t => set(t.split("\n").length)), // store the line count
},Save & close guards ○
await cfg.init(settings, {
onBeforeSave: async v => await serverAccepts(v.apiKey), // return false to block the save
onBeforeClose: () => confirm("Discard unsaved changes?"), // return false to keep it open
});Edits are a preview until Save. Field changes live in the DOM only and are written on Save (or
closeOnSave). Dismissing (Cancel / X / Esc / backdrop) discards them and reverts to the last persisted state. Abeforeunloadwarning fires if you try to leave the page with unsaved edits. (Full build; Lite simply doesn't write live edits until save, so there's nothing to revert.)
Custom controls ○
Bring your own control with render (build the node); get / set read and write its value (both default to el.value, so a wrapped input needs only render), and destroy cleans up timers/observers. gmConfig.type() packages a reusable control:
const colorType = gmConfig.type({
render: f => Object.assign(document.createElement("input"), { type: "color", value: f.value ?? "#000000" }),
});
await cfg.init({
bg: { label: "Background", default: "#ffffff", ...colorType },
accent: { label: "Accent", default: "#0a53f8", ...colorType },
clock: { // a widget that owns a timer cleans up via destroy()
label: "Clock", save: false,
render: () => { const el = document.createElement("span"); el._id = setInterval(() => el.textContent = new Date().toLocaleTimeString(), 1000); return el; },
get: () => null,
destroy: el => clearInterval(el._id),
},
});Language switch (reinit) ○
await cfg.init(buildSettings("en"), {
onChange: (key, value) => {
if (key === "lang") cfg.reinit(buildSettings(value)); // hot-swap labels, keep values + active tab
},
});API
Rows marked ○ are full-build only — in Lite they exist but are harmless no-ops (same API shape, nothing crashes). Everything else works identically in both.
Methods
| Method | Returns | Description |
|--------|---------|-------------|
| init(settings, opts?) | Promise<Vals> | Initialize and load persisted values |
| open(tab?) | Promise<boolean> | Show the dialog (optionally on a named tab); resolves true if saved |
| get(key?) | value / Vals | One value by key (typed: get<string>("k")), or all values |
| set(key, value) | boolean | Write a value (updates DOM, fires onChange); false if key unknown / not settable |
| save() | Promise | Persist current values |
| reset() | Promise | Restore defaults and persist |
| importExport() | Promise<boolean> | Backup/restore via a JSON prompt; true if applied |
| setField(key, props) | — | Merge new props into a field and re-render it |
| destroy() | — | Close and release everything (fires custom destroy hooks) |
| reinit(settings, title?) ○ | — | Hot-swap definitions, keeping live values and the active tab |
| getElement(key) | HTMLElement | A field's live DOM element, or null when closed |
| setAccent(color) ○ | — | Set accent live (any CSS color, or null to reset); auto-contrasts the Save text |
| setTheme(t) ○ | — | "light" / "dark" / "system" |
| gmConfig.type(def) ○ | FieldType | (static) package a reusable custom control |
Properties
| Property | Description |
|----------|-------------|
| ready | Promise<Vals> resolved once storage loads (same as init's return; rejects if the read throws) |
| isOpen | true while shown |
| title | resolved panel title |
Options (init's 2nd argument)
| Option | Description |
|--------|-------------|
| title | Panel title (default: the userscript @name, else "Settings") |
| storageKey | Storage key (default: the title) |
| menu | true / "Label" — register a userscript menu command that opens the dialog (needs the GM_registerMenuCommand grant) |
| onChange | (key, value) on every live edit and set() |
| onOpen / onSave / onClose / onReset | Lifecycle hooks |
| closeOnSave ○ | false keeps the panel open after saving (default true) |
| accent ○ | Accent color (any CSS color) |
| width ○ | Panel width (CSS length or px number); overrides the default clamp(320px,90%,460px) |
| onBeforeSave ○ | (values) => boolean \| void — after validation; false blocks the save |
| onBeforeClose ○ | (values) => boolean \| void — on dismiss when changed; false keeps it open |
| footerContent ○ | Node, string, or (cfg) => … shown left of the action buttons |
Theming & accessibility
- Dark mode follows the OS via
color-scheme/light-dark(). - Keyboard:
<dialog>traps focus; Esc dismisses (respectingonBeforeClose); tabs navigate with ← → Home End and carry ARIAtablist/tab/tabpanelroles. - Validation uses the browser's own messages, plus inline custom errors from
validate.
Accent tints native controls, the active tab, and the Save button:
await cfg.init(settings, { accent: "#e74c3c" });
cfg.setAccent("#22c55e"); // live
cfg.setAccent(null); // resetQuick theming via two CSS variables on .gc-p (--gc-a accent, --gc-r radius), or target gc-* classes directly:
GM_addStyle(`.gc-p { --gc-a: #e74c3c; --gc-r: 0 }`); // red accent, square cornersFull vs Lite
Both builds are compiled from the same source and expose the same gmConfig class — choose one by @require URL, change nothing else:
// @require https://cdn.jsdelivr.net/npm/@kobzi/gmconfig@latest/dist/gmConfig.iife.min.js // full — ~16 kB min / ~6.5 kB gzip
// @require https://cdn.jsdelivr.net/npm/@kobzi/gmconfig@latest/dist/gmConfig.lite.iife.min.js // lite — ~7.3 kB min / ~3.4 kB gzipLite covers the common case: native fields, button, hidden, flat section, depends / showWhen, file, attrs, tooltips, import/export, reset, onChange, menu. Full adds everything marked ○ throughout this README — radio / multicheck / list, tabs / rows / labelPos, custom controls, accent + theme + width, async validate, computed descriptions, collapsible sections, reinit, the onBefore* guards, the unsaved-changes warning, async/map options + datalist, and a localStorage fallback (lite is GM-storage-only).
Reach for lite when you need a handful of plain settings and the smallest bundle; full otherwise. Because both builds share one source, the API shape is identical: full-only methods exist in lite as harmless no-ops (calling reinit() won't crash), and TypeScript will let you write full-only field props — lite silently ignores them at runtime. Anything lite supports behaves exactly as documented above.
Demo
demo/demo.user.js is a ready-to-run, heavily commented showcase of the full build: every field type including a list of highlight rules (reorder, max, per-item showWhen), tabs / sections / rows, depends chains, async validate, computed descriptions, async options / datalist providers, attrs, labelPos, the menu shortcut, five custom controls (star rating, tag input, live clock, hotkey recorder, CSV loader with upload + fetch-from-URL), live theming, and reinit language switching. Paste it into Tampermonkey — it opens on first run.
Migrating from GM_config
// Before — sizzlemctwizzle/GM_config
new GM_config({
id: "MyConfig", title: "Settings",
fields: {
name: { label: "Name", type: "text", default: "User" },
count: { label: "Count", type: "int", default: 5 },
on: { label: "On", type: "checkbox", default: true },
},
events: { save() { console.log(this.get("name")); } },
}).open();
// After — gmConfig
const cfg = new gmConfig();
await cfg.init({
name: { type: "text", label: "Name", default: "User" },
count: { type: "number", label: "Count", default: 5, min: 0 }, // 'int' → 'number' + min
on: { type: "checkbox", label: "On", default: true },
}, {
title: "Settings",
storageKey: "MyConfig", // match the old id for storage compatibility
onSave: () => console.log(cfg.get("name")),
});
cfg.open();| GM_config | gmConfig |
|---|---|
| new GM_config({ id, fields, events }) | new gmConfig() + await cfg.init(fields, opts) |
| type: "int" / "float" | type: "number" + min / step |
| type: "unsigned int" | type: "number", min: 0 |
| events: { save, open, close } | onSave / onOpen / onClose options |
| id for storage | storageKey option |
| css: in constructor | GM_addStyle(...) on gc-* classes |
| sync API · iframe | async (await) · native <dialog> |
Build
npm install && npm run buildBoth variants — full (dist/gmConfig.iife.min.js) and lite (dist/gmConfig.lite.iife.min.js) — compile from the single source src/gmConfig.ts: an esbuild --define:FULL=true/false flag selects the build, and dead-code elimination strips everything the lite build doesn't use. Each variant ships as IIFE (minified + readable dev) and ESM, with shared TypeScript declarations. npm run build:size prints raw + gzip sizes for both bundles.
License
MIT
