This skill should be used when creating custom windows with ApplicationV2, building forms with FormApplication, using Dialog for prompts, understanding the render lifecycle, or migrating from Application v1 to ApplicationV2.
Domain: Foundry VTT Module/System Development Status: Production-Ready Last Updated: 2026-01-05
Applications are the window/dialog system in Foundry. ApplicationV2 (V12+) is the modern framework replacing the legacy Application v1 (deprecated, removed in V16).
const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api;
class MyWindow extends HandlebarsApplicationMixin(ApplicationV2) {
static DEFAULT_OPTIONS = {
id: "my-window",
classes: ["my-module"],
position: { width: 400, height: "auto" },
window: {
title: "My Window",
icon: "fas fa-gear"
}
};
static PARTS = {
main: {
template: "modules/my-module/templates/window.hbs"
}
};
async _prepareContext(options) {
return {
message: "Hello World"
};
}
}
// Usage
new MyWindow().render(true);
class MyForm extends HandlebarsApplicationMixin(ApplicationV2) {
static DEFAULT_OPTIONS = {
id: "my-form",
tag: "form", // CRITICAL for form handling
window: { title: "Settings" },
position: { width: 500 },
form: {
handler: MyForm.#onSubmit,
submitOnChange: false,
closeOnSubmit: true
}
};
static PARTS = {
form: {
template: "modules/my-module/templates/form.hbs"
}
};
async _prepareContext() {
return {
settings: this.settings
};
}
static async #onSubmit(event, form, formData) {
const data = foundry.utils.expandObject(formData.object);
console.log("Submitted:", data);
// Process form data
}
}
const confirmed = await foundry.applications.api.DialogV2.confirm({
window: { title: "Confirm" },
content: "<p>Delete this item?</p>"
});
if (confirmed) {
await item.delete();
}
const name = await foundry.applications.api.DialogV2.prompt({
window: { title: "Enter Name" },
content: "<p>What is your character's name?</p>",
label: "Submit"
});
const result = await foundry.applications.api.DialogV2.wait({
window: { title: "Choose Action" },
content: "<p>What would you like to do?</p>",
buttons: [{
icon: "fas fa-check",
label: "Accept",
action: "accept"
}, {
icon: "fas fa-times",
label: "Decline",
action: "decline"
}]
});
if (result === "accept") {
// Handle accept
}
const data = await foundry.applications.api.DialogV2.prompt({
window: { title: "Configure" },
content: `
<div class="form-group">
<label>Name</label>
<input type="text" name="name" value="">
</div>
<div class="form-group">
<label>Level</label>
<input type="number" name="level" value="1">
</div>
`,
ok: {
callback: (event, button, dialog) => {
const form = dialog.querySelector("form");
return new FormDataExtended(form).object;
}
}
});
class MyApp extends HandlebarsApplicationMixin(ApplicationV2) {
// Prepare data for template
async _prepareContext(options) {
return { items: this.items };
}
// Prepare data for specific part
async _preparePartContext(partId, context) {
if (partId === "list") {
context.sortedItems = this.sortItems(context.items);
}
return context;
}
// After first render only
async _onFirstRender(context, options) {
this.setupInitialState();
}
// After every render
async _onRender(context, options) {
this.attachEventListeners();
}
}
render(true) called
→ _preRender()
→ _prepareContext()
→ _preparePartContext() (per part)
→ Template rendering
→ _onFirstRender() (first time only)
→ _onRender()
→ Hook: renderMyApp
class MyApp extends HandlebarsApplicationMixin(ApplicationV2) {
static DEFAULT_OPTIONS = {
actions: {
delete: MyApp.#onDelete,
edit: MyApp.#onEdit
}
};
static async #onDelete(event, target) {
const itemId = target.dataset.itemId;
await this.deleteItem(itemId);
}
static #onEdit(event, target) {
const itemId = target.dataset.itemId;
this.editItem(itemId);
}
}
Template:
<button type="button" data-action="delete" data-item-id="{{item.id}}">
Delete
</button>
async _onRender(context, options) {
this.element.querySelector(".custom-button")
?.addEventListener("click", this._onCustomClick.bind(this));
}
_onCustomClick(event) {
event.preventDefault();
// Handle click
}
static PARTS = {
header: {
template: "modules/my-mod/templates/header.hbs"
},
tabs: {
template: "templates/generic/tab-navigation.hbs"
},
content: {
template: "modules/my-mod/templates/content.hbs",
scrollable: [""] // Enable scroll preservation
},
footer: {
template: "modules/my-mod/templates/footer.hbs"
}
};
static PARTS = {
tabs: { template: "templates/generic/tab-navigation.hbs" },
details: { template: "templates/details.hbs" },
inventory: { template: "templates/inventory.hbs" }
};
static TABS = {
primary: {
tabs: [
{ id: "details", label: "Details" },
{ id: "inventory", label: "Inventory" }
],
initial: "details"
}
};
async _prepareContext(options) {
const context = await super._prepareContext(options);
context.tabs = this._prepareTabs();
return context;
}
async _preparePartContext(partId, context) {
if (["details", "inventory"].includes(partId)) {
context.tab = context.tabs[partId];
}
return context;
}
For maintenance of existing code only:
class LegacyForm extends FormApplication {
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "legacy-form",
title: "Legacy Form",
template: "modules/my-mod/templates/form.hbs",
width: 400,
closeOnSubmit: true
});
}
getData() {
return { data: this.object };
}
async _updateObject(event, formData) {
await this.object.update(formData);
}
activateListeners(html) {
super.activateListeners(html);
html.find(".button").click(this._onClick.bind(this));
}
}
// WRONG - form submission won't work
static DEFAULT_OPTIONS = {
form: { handler: MyApp.#onSubmit }
};
// CORRECT
static DEFAULT_OPTIONS = {
tag: "form",
form: { handler: MyApp.#onSubmit }
};
<!-- WRONG - triggers form submission -->
<button>Click Me</button>
<!-- CORRECT - won't submit form -->
<button type="button">Click Me</button>
// WRONG - DialogV2 cannot re-render
const dialog = new DialogV2({...});
await dialog.render(true);
await dialog.render(true); // Error!
// CORRECT - use ApplicationV2 for re-renderable windows
class MyDialog extends HandlebarsApplicationMixin(ApplicationV2) {}
// Always await async operations
async _prepareContext(options) {
const data = await this.loadData(); // OK
return { data };
}
// WRONG - loses context
this.element.addEventListener("click", this._onClick);
// CORRECT - bind context
this.element.addEventListener("click", this._onClick.bind(this));
// Or use arrow function
this.element.addEventListener("click", (e) => this._onClick(e));
{{!-- Use standard-form class --}}
<div class="standard-form">
<div class="form-group">
<label>Field</label>
<input type="text" name="field">
</div>
</div>
HandlebarsApplicationMixin(ApplicationV2)static DEFAULT_OPTIONS with id, classes, positionstatic PARTS for templates_prepareContext() for template datatag: "form" for form applicationsform.handler for form submissiondata-action attributes with static actionstype="button" on non-submit buttonsDialogV2.confirm() for yes/no promptsDialogV2.prompt() for text inputDialogV2.wait() for custom buttonsLast Updated: 2026-01-05 Status: Production-Ready Maintainer: ImproperSubset