bootstrap-component-js
Add JavaScript behavior to a Bootstrap 6 component. Use when writing a component's TypeScript source, extending BaseComponent, wiring the data API, adding events or config options, or integrating Floating UI or a third-party library.
How do I install this agent skill?
npx skills add https://github.com/twbs/bootstrap --skill bootstrap-component-jsIs this agent skill safe to install?
- Gen Agent Trust Hubpass
This skill is a developer guide for creating Bootstrap 6 JavaScript components. It provides standard TypeScript templates and architectural patterns used by the Bootstrap project. No security risks were identified.
- Socketpass
No alerts
- Snykpass
Risk: LOW · No issues
What does this agent skill do?
Bootstrap 6 component JavaScript
Every Bootstrap plugin is a TypeScript module in js/src/ that extends BaseComponent, which handles instance registration, config merging, and teardown. Follow the shared structure and a component gets getInstance(), getOrCreateInstance(), data-bs-* config, namespaced events, and dispose() without writing any of it.
The sources are TypeScript, but the runtime patterns are the same ones v5 used with types layered on top. Bootstrap compiles them to JavaScript and emits its own declarations, so consumers need no @types/bootstrap.
For the component's styles, see the bootstrap-component skill.
Workflow
- Step 1: Scaffold the module
- Step 2: Define config options
- Step 3: Implement behavior and events
- Step 4: Wire the data API
- Step 5: Position with Floating UI (optional)
- Step 6: Wrap a third-party library (optional)
- Step 7: Clean up in dispose()
- Step 8: Register and test
- Step 9: Verify
Step 1: Scaffold the module
Create js/src/<name>.ts. Read js/src/alert.ts first — it's the smallest complete example.
/**
* --------------------------------------------------------------------------
* Bootstrap widget.ts
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
import BaseComponent from './base-component.js'
import EventHandler from './dom/event-handler.js'
/**
* Constants
*/
const NAME = 'widget'
const DATA_KEY = 'bs.widget'
const EVENT_KEY = `.${DATA_KEY}`
const DATA_API_KEY = '.data-api'
const EVENT_SHOW = `show${EVENT_KEY}`
const EVENT_SHOWN = `shown${EVENT_KEY}`
const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}`
const CLASS_NAME_SHOW = 'show'
const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="widget"]'
/**
* Class definition
*/
class Widget extends BaseComponent {
// Getters
static override get NAME(): string {
return NAME
}
}
export default Widget
NAMEis the only required static.BaseComponentderivesDATA_KEY(bs.<name>) andEVENT_KEY(.bs.<name>) from it, stores the instance on the element withData.set(), and disposes any previous instance bound to the same element.- Keep the section comments (
Constants,Class definition,Data API implementation) and member grouping (constructor,// Getters,// Public,// Private,// Static) — every module injs/src/looks the same. - Repo style: ES modules only, no semicolons, 2-space indent, single quotes, and
overrideon every member that overridesBaseComponentorConfig. - Relative imports keep the ESM
.jsextension even though the file on disk is.ts, becausetsconfig.jsonsetsmoduleResolution: nodenext. Rolldown, Vite, and Vitest all map the specifier back to the.tssource. - The compiler is strict in ways worth knowing before you write:
verbatimModuleSyntaxwants type imports marked (import EventHandler, { type BootstrapEvent } from './dom/event-handler.js'), anderasableSyntaxOnlyrules out enums, namespaces, and constructor parameter properties. - Declare instance state as
protected declare _foo: Typeand assign it in the constructor, the waytoast.tsdoes.declareis type-only; a real field without an initializer would emit_foo = undefinedunderuseDefineForClassFieldsand wipe whatever the base constructor set. - v6 has no
defineJQueryPluginand nojs/src/util.ts. Helpers live injs/src/util/; DOM wrappers injs/src/dom/(event-handler,selector-engine,manipulator,data).
Step 2: Define config options
Declare the config shape, the defaults, and the runtime types as module constants, then expose them as statics:
type WidgetConfig = {
animation: boolean
delay: number
}
const Default: WidgetConfig = {
animation: true,
delay: 5000
}
const DefaultType = {
animation: 'boolean',
delay: 'number'
}
class Widget extends BaseComponent {
protected declare _config: WidgetConfig
protected declare _timeout: number | null
constructor(element?: string | Element | null, config?: Partial<WidgetConfig> | null) {
super(element, config)
this._timeout = null
}
// Getters
static override get Default(): WidgetConfig {
return Default
}
static override get DefaultType(): Record<string, string> {
return DefaultType
}
static override get NAME(): string {
return NAME
}
}
export default Widget
export type { WidgetConfig }
- The config type does double duty:
Defaultis typed with it, the constructor accepts aPartial<>of it since callers pass a subset, and redeclaringprotected declare _config: WidgetConfignarrowsBaseComponent's loose config sothis._config.delayis anumber.DefaultTypestays a plain string map — it drives the runtime check, not the compiler. Export the type; the type tests import it. - Merge order, applied by
Config._mergeConfigObj()(last wins):Default→ the JSON indata-bs-config→ individualdata-bs-*attributes → the object passed to the constructor. You don't write any of this. data-bs-*names are camelCased when read, sodata-bs-auto-hide="false"becomesautoHide: false.- Every key in
DefaultTypeis validated and throws aTypeErroron mismatch. Values are matched as regex, so unions work:'boolean','(number|string)','(string|element|function)'. - Normalize a value before validation by overriding
_configAfterMerge(config)— the usual place to resolve a selector string into an element or coerce a shorthand.
Step 3: Implement behavior and events
class Widget extends BaseComponent {
// Public
show(): void {
const showEvent = EventHandler.trigger(this._element, EVENT_SHOW)
if (showEvent.defaultPrevented) {
return
}
this._element.classList.add(CLASS_NAME_SHOW)
this._queueCallback(() => {
EventHandler.trigger(this._element, EVENT_SHOWN)
}, this._element, this._config.animation)
}
}
- Events come in pairs: an infinitive fired before the action (
show.bs.widget, cancelable) and a past participle after it (shown.bs.widget). Always bail when the "before" event isdefaultPrevented. _queueCallback(callback, element, isAnimated)runs the callback after the CSS transition onelementfinishes, and skips it if the instance was disposed mid-transition. Pass the animation flag from config so non-animated components fire immediately.- Add a payload when listeners need context:
EventHandler.trigger(this._element, EVENT_SHOWN, { relatedTarget }).trigger()returns aBootstrapEvent, anEventwidened with the payload keys, so reading them back in a handler needs no cast. - Query with
SelectorEngine(findOne,find,next,prev,getElementFromSelector) instead of rawquerySelector, and read or write attributes withManipulator. - Bind internal listeners with
EventHandler.on(this._element, EVENT_MOUSEOVER, …)using the namespaced constants, sodispose()removes them in one call.
Step 4: Wire the data API
The data API goes at the bottom of the module, after the class, as an import side effect:
/**
* Data API implementation
*/
EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (event) {
event.preventDefault()
Widget.getOrCreateInstance(this).toggle()
})
- Delegate from
documentand namespace the event with.data-apiso it stays distinguishable from user listeners. - Keep
functionrather than an arrow when you need the delegated element:EventHandlertypes the callback'sthis, sothisis the match forSELECTOR_DATA_TOGGLE. Use an arrow and reach forevent.targetonly when you need the actual click target. - Prefer the shared helpers in
js/src/util/component-functions.tsover hand-rolled handlers:enableDismissTrigger(Widget)wires[data-bs-dismiss="widget"]tohide(). Pass a method name for anything else:enableDismissTrigger(Alert, 'close').eventActionOnPlugin(Widget, 'click', SELECTOR_DATA_TOGGLE, 'toggle')handles the "trigger points at one or more targets viadata-bs-target" case and skips disabled elements.
- Because these are side effects, the data API only exists once the module is imported. Anything importing a single component file gets that component's data API and nothing else.
Step 5: Position with Floating UI (optional)
Menus, tooltips, and popovers position with @floating-ui/dom, an optional peer dependency. Guard for it in the constructor before calling super() — legal here because the class declares its fields instead of initializing them:
import { autoUpdate, computePosition } from '@floating-ui/dom'
class Widget extends BaseComponent {
protected declare _floatingCleanup: (() => void) | null
constructor(element?: string | Element | null, config?: Partial<WidgetConfig> | null) {
if (typeof computePosition === 'undefined') {
throw new TypeError('Bootstrap\'s widgets require Floating UI (https://floating-ui.com)')
}
super(element, config)
this._floatingCleanup = null
}
}
Create the positioner when the element is shown and keep the cleanup function so it can be cancelled:
class Widget extends BaseComponent {
// Private
protected _createFloating(): void {
this._updateFloatingPosition()
this._floatingCleanup = autoUpdate(this._element, this._floatingEl, () => this._updateFloatingPosition())
}
protected _disposeFloating(): void {
if (this._floatingCleanup) {
this._floatingCleanup()
this._floatingCleanup = null
}
}
}
js/src/util/floating-ui.ts handles placement so you don't reimplement it: getDefaultPlacement() flips -start / -end for RTL, parseResponsivePlacement() and getResponsivePlacement() support data-bs-placement="bottom-start md:top-end", and createBreakpointListeners() / disposeBreakpointListeners() re-position on breakpoint changes.
Step 6: Wrap a third-party library (optional)
js/src/datepicker.ts wraps vanilla-calendar-pro and is the reference for this pattern:
- Keep Bootstrap's config surface. Users set
data-bs-*options; a private builder (_buildCalendarOptions()) translates them into the library's option shape. - Instantiate in a private initializer called from the constructor and hold the instance on a private field typed with the library's own type (
protected declare _calendar: Calendar | null). - Re-emit the library's callbacks as namespaced Bootstrap events so consumers only learn one API.
- Call the library's teardown in
dispose()beforesuper.dispose(), and null the field. - Add the package to
peerDependenciesinpackage.json, guard for its absence, and document the extra install step on the component's docs page.
Don't fork or vendor the library, and don't expose its instance as public API — anything users need should be a method or event on the Bootstrap class.
Step 7: Clean up in dispose()
class Widget extends BaseComponent {
// Public
override dispose(): void {
clearTimeout(this._timeout!)
this._observer?.disconnect()
this._disposeFloating()
super.dispose()
}
}
super.dispose() removes the Data entry, removes every listener bound on this._element under EVENT_KEY, and nulls the instance's own properties. Everything else is yours to undo first: timers, MutationObserver / ResizeObserver instances, listeners bound on document or window, Floating UI cleanups, and third-party instances. Leaks here show up as failing "should dispose" specs.
Step 8: Register and test
-
Add the export to
js/src/index.ts:export { default as Widget } from './widget.js'That barrel is the package entry point and feeds both
dist/js/bootstrap.jsand the bundle. Nothing else needs updating: Rolldown builds fromjs/src/index.ts,build/build-plugins.mjsglobsjs/src/**/*.tsfor the per-plugin files injs/dist/, and Vitest picks upjs/tests/unit/**/*.spec.js. -
Extend both type tests.
js/tests/types/api.tsasserts the public surface against the sources;js/tests/types/consumer.tsasserts the same surface against the shippedjs/dist/*.d.ts, imported through the package name the way a downstream project does. Add the class, its config type, and its methods to both — a declaration-emit bug passes the first and fails the second. -
Add a spec at
js/tests/unit/<name>.spec.js. Specs are plain JavaScript and run in a real Chromium through Vitest browser mode:import Widget from '../../src/widget.js' import { clearFixture, getFixture } from '../helpers/fixture.js' describe('Widget', () => { let fixtureEl beforeAll(() => { fixtureEl = getFixture() }) afterEach(() => { clearFixture() }) it('should expose the plugin name and data key', () => { expect(Widget.NAME).toEqual('widget') expect(Widget.DATA_KEY).toEqual('bs.widget') }) })describe,it,expect, and the hooks are globals (globals: trueinjs/tests/vitest.config.mts), so don't import them. Importvifromvitestwhen you need spies or fake timers — that's the preferred API for new specs.js/tests/vitest-setup.jsmaps the Jasmine API the older specs are written against onto Vitest, sospyOn,jasmine.clock(),toHaveClass,toBeTrue,toBeFalse, andtoHaveSizestill work. Two gotchas: the shimmedspyOnstubs the method (add.and.callThrough()for the real one), and Vitest 4 dropped thedonecallback, so an asynchronous test returns a promise and resolves it from the listener.- Cover the constructor with both a selector and an element, config coming from
data-bs-*, each public method, both events including the cancelable one, the data API without manual instantiation, anddispose(). Coverage counts every file injs/src, so a component with no spec drags the run below the thresholds injs/tests/vitest.config.mts(90% of statements, functions, and lines; 88% of branches) and fails the build.
-
Document it on the component's docs page: add
js: optional(orjs: required) to the frontmatter,<JsDataAttributes />in an### Optionssection, and<BsTable>tables for options, methods, and events. Demo snippets live insite/src/assets/partials/snippets.jsand render with<JsDocs name="live-widget" file="site/src/assets/partials/snippets.js" />.
Step 9: Verify
npm run js-lintnpm run js-typecheck—tsc --noEmitoverjs/srcand the type tests.npm run js-test-unit, ornpm run js-testto add the two Rolldown integration bundles that prove the module is tree-shakeable. Playwright needs its browser once per machine (npx playwright install chromium), andnpm run js-debugruns the specs in a visible browser.npm run js— Rolldown compiles the sources todist/jsandjs/dist, terser minifies, andtscemits the.d.tsfiles. Check the bundle grew by roughly what you'd expect;npm run bundlewatchenforces size budgets.npm startand exercise the component on its docs page, with the keyboard as well as the mouse.
In your own project
package.json maps bootstrap/js/src/*.js to the compiled js/dist/*.js and its declarations, so the same specifier works from JavaScript and TypeScript, and custom components can build on the same base class:
import BaseComponent from 'bootstrap/js/src/base-component.js'
import EventHandler from 'bootstrap/js/src/dom/event-handler.js'
class Widget extends BaseComponent {
static override get NAME(): string {
return 'widget'
}
}
Two things to keep in mind. Importing a Bootstrap component module runs its data API as a side effect, which is what you want in an app entry point but not inside a library. And DATA_KEY is derived as bs.<name>, so pick a NAME that doesn't collide with a Bootstrap plugin or your instance will replace theirs on shared elements.
How can the creator link this skill?
Add the canonical catalog link to the repository README so users can inspect current installs and available audits. The publishing guide covers the complete discovery path.
<a href="https://skillzs.dev/skills/twbs/bootstrap/bootstrap-component-js">View bootstrap-component-js on skillZs</a>