| name | fvtt-sheets |
| description | This skill should be used when creating or extending ActorSheet/ItemSheet classes, implementing getData or _prepareContext, binding events with activateListeners, handling drag/drop, or migrating from ApplicationV1 to ApplicationV2. Covers both legacy V1 and modern V2 patterns. |
Foundry VTT Sheets
Domain: Foundry VTT Module/System Development Status: Production-Ready Last Updated: 2026-01-04
Overview
Document sheets (ActorSheet, ItemSheet) are the primary UI for interacting with game entities. Foundry supports two patterns: legacy ApplicationV1 (until V16) and modern ApplicationV2 (V12+).
When to Use This Skill
- Creating custom character or item sheets
- Extending existing sheet classes
- Adding interactivity (rolls, item management)
- Implementing drag/drop functionality
- Migrating V1 sheets to V2
V1 vs V2 Quick Comparison
| Aspect | V1 (Legacy) | V2 (Modern) |
|---|---|---|
| Config | static get defaultOptions() |
static DEFAULT_OPTIONS |
| Data | getData() |
async _prepareContext() |
| Events | activateListeners(html) |
static actions + _onRender() |
| Templates | Single template | Multi-part PARTS system |
| Re-render | Full sheet | Partial by part |
| Support | Until V16 | Current standard |
ApplicationV1 Sheets
Basic Structure
export class MyActorSheet extends ActorSheet {
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["my-system", "sheet", "actor"],
template: "systems/my-system/templates/actor-sheet.hbs",
width: 600,
height: 600,
tabs: [{
navSelector: ".sheet-tabs",
contentSelector: ".sheet-body",
initial: "description"
}],
dragDrop: [{
dragSelector: ".item-list .item",
dropSelector: null
}]
});
}
// Dynamic template based on actor type
get template() {
return `systems/my-system/templates/actor-${this.actor.type}-sheet.hbs`;
}
}
getData() - Preparing Template Context
getData() {
const context = super.getData();
const actorData = this.actor.toObject(false);
// Add data to context
context.system = actorData.system;
context.flags = actorData.flags;
context.items = actorData.items;
// Organize items by type
context.weapons = context.items.filter(i => i.type === "weapon");
context.spells = context.items.filter(i => i.type === "spell");
// Enrich HTML (sync in V1)
context.enrichedBio = TextEditor.enrichHTML(
this.actor.system.biography,
{ secrets: this.actor.isOwner, async: false }
);
return context;
}
Key Points:
- Context has NO automatic relation to document data
- Everything template needs MUST be explicitly added
{{system.hp.value}}reads from contextname="system.hp.value"writes to document
activateListeners() - Event Binding
activateListeners(html) {
// ALWAYS call super first
super.activateListeners(html);
// Skip if not editable
if (!this.isEditable) return;
// Roll handlers
html.on("click", ".rollable", this._onRoll.bind(this));
// Item management
html.on("click", ".item-create", this._onItemCreate.bind(this));
html.on("click", ".item-edit", this._onItemEdit.bind(this));
html.on("click", ".item-delete", this._onItemDelete.bind(this));
}
async _onRoll(event) {
event.preventDefault();
const element = event.currentTarget;
const { rollType, formula, label } = element.dataset;
const roll = new Roll(formula, this.actor.getRollData());
await roll.evaluate();
roll.toMessage({
speaker: ChatMessage.getSpeaker({ actor: this.actor }),
flavor: label
});
}
async _onItemCreate(event) {
event.preventDefault();
const type = event.currentTarget.dataset.type;
await this.actor.createEmbeddedDocuments("Item", [{
name: `New ${type.capitalize()}`,
type: type
}]);
}
async _onItemDelete(event) {
event.preventDefault();
const li = $(event.currentTarget).closest(".item");
const item = this.actor.items.get(li.data("itemId"));
await item.delete();
li.slideUp(200, () => this.render(false));
}
Drag & Drop (V1)
// Automatic via defaultOptions
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
dragDrop: [{
dragSelector: ".item-list .item",
dropSelector: null
}]
});
}
// Override handlers as needed
_onDragStart(event) {
const li = event.currentTarget;
const item = this.actor.items.get(li.dataset.itemId);
event.dataTransfer.setData("text/plain", JSON.stringify(item.toDragData()));
}
async _onDrop(event) {
const data = TextEditor.getDragEventData(event);
if (data.type === "Item") {
return this._onDropItem(event, data);
}
}
async _onDropItem(event, data) {
if (!this.actor.isOwner) return false;
const item = await Item.implementation.fromDropData(data);
// Prevent dropping on self
if (this.actor.uuid === item.parent?.uuid) return;
return this.actor.createEmbeddedDocuments("Item", [item.toObject()]);
}
Tab Navigation (V1)
<!-- Template structure -->
<nav class="sheet-tabs">
<a class="item" data-tab="description">Description</a>
<a class="item" data-tab="items">Items</a>
</nav>
<section class="sheet-body">
<div class="tab" data-group="primary" data-tab="description">
<!-- Description content -->
</div>
<div class="tab" data-group="primary" data-tab="items">
<!-- Items content -->
</div>
</section>
ApplicationV2 Sheets
Basic Structure
class MyActorSheet extends foundry.applications.api.HandlebarsApplicationMixin(
foundry.applications.sheets.ActorSheetV2
) {
static DEFAULT_OPTIONS = {
classes: ["my-system", "sheet", "actor"],
tag: "form",
window: {
resizable: true
},
position: {
width: 600,
height: 600
},
actions: {
rollSkill: this.#onRollSkill,
createItem: this.#onCreateItem,
deleteItem: this.#onDeleteItem
}
}
static PARTS = {
header: {
template: "systems/my-system/templates/actor/header.hbs"
},
tabs: {
template: "templates/generic/tab-navigation.hbs"
},
description: {
template: "systems/my-system/templates/actor/description.hbs",
scrollable: [""]
},
items: {
template: "systems/my-system/templates/actor/items.hbs",
scrollable: [""]
}
}
static TABS = {
primary: {
tabs: [
{ id: "description" },
{ id: "items" }
],
labelPrefix: "MYSYS.TAB",
initial: "description"
}
}
}
_prepareContext() - Async Data Preparation
async _prepareContext(options) {
const context = await super._prepareContext(options);
// Add tabs
context.tabs = this._prepareTabs(this.tabGroups.primary);
// Add system data
context.system = this.document.system;
// Organize items
context.weapons = this.document.items.filter(i => i.type === "weapon");
context.spells = this.document.items.filter(i => i.type === "spell");
// Enrich HTML (MUST be async in V2)
context.enrichedBio = await TextEditor.enrichHTML(
this.document.system.biography,
{ async: true, relativeTo: this.document }
);
return context;
}
async _preparePartContext(partId, context) {
switch (partId) {
case "description":
case "items":
context.tab = context.tabs[partId];
break;
}
return context;
}
Static Actions (V2 Event Handling)
static DEFAULT_OPTIONS = {
actions: {
rollSkill: this.#onRollSkill,
createItem: this.#onCreateItem,
deleteItem: this.#onDeleteItem
}
}
// Action handlers MUST be static with # prefix
static #onRollSkill(event, target) {
// 'this' is the application instance
// 'target' is the clicked element
const skillId = target.dataset.skillId;
const skill = this.document.system.skills[skillId];
const roll = new Roll("1d20 + @mod", { mod: skill.value });
roll.evaluate().then(r => {
r.toMessage({
speaker: ChatMessage.getSpeaker({ actor: this.document }),
flavor: `${skill.label} Check`
});
});
}
static async #onCreateItem(event, target) {
const type = target.dataset.type;
await this.document.createEmbeddedDocuments("Item", [{
name: `New ${type.capitalize()}`,
type: type
}]);
}
static async #onDeleteItem(event, target) {
const itemId = target.closest("[data-item-id]").dataset.itemId;
const item = this.document.items.get(itemId);
await item.delete();
}
Template usage:
<button type="button" data-action="rollSkill" data-skill-id="athletics">
Roll Athletics
</button>
Tab Navigation (V2)
Four required elements:
1. Static PARTS with tab templates 2. Static TABS configuration 3. Prepare tabs in _prepareContext 4. Set tab in _preparePartContext
<!-- Tab content template - MUST include data-group, data-tab, and {{tab.cssClass}} -->
<div class="tab-content {{tab.cssClass}}" data-group="primary" data-tab="description">
<!-- Content -->
</div>
Drag & Drop (V2)
ActorSheetV2 provides automatic drag/drop for items. Just use:
<li class="item draggable" data-item-id="{{item._id}}">
<!-- Item content -->
</li>
For base ApplicationV2, manual setup required:
#dragDrop;
constructor(options = {}) {
super(options);
this.#dragDrop = this.options.dragDrop.map(d => {
d.permissions = {
dragstart: this._canDragStart.bind(this),
drop: this._canDragDrop.bind(this)
};
d.callbacks = {
dragstart: this._onDragStart.bind(this),
drop: this._onDrop.bind(this)
};
return new foundry.applications.ux.DragDrop(d);
});
}
_onRender(context, options) {
this.#dragDrop.forEach(d => d.bind(this.element));
}
Common Pitfalls
1. Forgetting super.activateListeners()
// WRONG - breaks base functionality
activateListeners(html) {
html.on("click", ".rollable", this._onRoll.bind(this));
}
// CORRECT
activateListeners(html) {
super.activateListeners(html);
html.on("click", ".rollable", this._onRoll.bind(this));
}
2. Context Binding Issues
// WRONG - loses 'this' context
html.on("click", ".rollable", this._onRoll);
// CORRECT
html.on("click", ".rollable", this._onRoll.bind(this));
3. Memory Leaks from Global Listeners
// WRONG - binds globally on every render
activateListeners(html) {
super.activateListeners(html);
$(document).on("click", this._onClick.bind(this));
}
// CORRECT - namespace and unbind first
activateListeners(html) {
super.activateListeners(html);
$(document).off("click.mysheet").on("click.mysheet", this._onClick.bind(this));
}
// Clean up on close
close(options) {
$(document).off("click.mysheet");
return super.close(options);
}
4. V2 Static Action Mistakes
// WRONG - action handler isn't static
static DEFAULT_OPTIONS = {
actions: {
roll: this._onRoll // Error!
}
}
// CORRECT - use static private method
static DEFAULT_OPTIONS = {
actions: {
roll: this.#onRoll
}
}
static #onRoll(event, target) {
// ...
}
5. V2 Partial Re-render Hook Multiplication
// PROBLEM - element added multiple times
Hooks.on("renderMySheet", (app, html, data) => {
html.append("<div class='custom'></div>");
});
// SOLUTION - check if exists
Hooks.on("renderMySheet", (app, html, data) => {
if (!html.querySelector(".custom")) {
html.append("<div class='custom'></div>");
}
});
6. Form Data Type Mismatches
<!-- WRONG - saves as string -->
<input type="text" name="system.level" value="{{system.level}}"/>
<!-- CORRECT - saves as number -->
<input type="text" name="system.level" value="{{system.level}}" data-dtype="Number"/>
<!-- Checkbox must use checked helper -->
<input type="checkbox" name="system.equipped" {{checked system.equipped}}/>
7. Async in V1 vs V2
// V1 - getData is sync, use async: false
getData() {
context.enrichedBio = TextEditor.enrichHTML(bio, { async: false });
return context;
}
// V2 - _prepareContext is async, use async: true
async _prepareContext(options) {
context.enrichedBio = await TextEditor.enrichHTML(bio, { async: true });
return context;
}
Implementation Checklist
V1 Sheet
- Extend ActorSheet or ItemSheet
- Define
static get defaultOptions()with template, classes, tabs - Implement
getData()returning context object - Call
super.activateListeners(html)first - Check
this.isEditablebefore binding edit controls - Use
.bind(this)for all event handlers - Clean up global event listeners in
close()
V2 Sheet
- Extend ActorSheetV2 with HandlebarsApplicationMixin
- Define
static DEFAULT_OPTIONSwithtag: "form" - Define
static PARTSfor each template section - Define
static TABSif using tabs - Implement
async _prepareContext()withawait super._prepareContext() - Implement
_preparePartContext()for tab data - Use
static actionswith#prefix handlers - Use
.draggableclass anddata-item-idfor drag/drop
References
Last Updated: 2026-01-04 Status: Production-Ready Maintainer: ImproperSubset