---
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
```javascript
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
```javascript
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 context
- `name="system.hp.value"` writes to document
### activateListeners() - Event Binding
```javascript
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)
```javascript
// 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)
```html
```
## ApplicationV2 Sheets
### Basic Structure
```javascript
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
```javascript
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)
```javascript
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:
```html
```
### 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**
```html
```
### Drag & Drop (V2)
ActorSheetV2 provides automatic drag/drop for items. Just use:
```html
```
For base ApplicationV2, manual setup required:
```javascript
#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()
```javascript
// 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
```javascript
// 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
```javascript
// 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
```javascript
// 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
```javascript
// PROBLEM - element added multiple times
Hooks.on("renderMySheet", (app, html, data) => {
html.append("");
});
// SOLUTION - check if exists
Hooks.on("renderMySheet", (app, html, data) => {
if (!html.querySelector(".custom")) {
html.append("");
}
});
```
### 6. Form Data Type Mismatches
```html
```
### 7. Async in V1 vs V2
```javascript
// 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.isEditable` before 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_OPTIONS` with `tag: "form"`
- [ ] Define `static PARTS` for each template section
- [ ] Define `static TABS` if using tabs
- [ ] Implement `async _prepareContext()` with `await super._prepareContext()`
- [ ] Implement `_preparePartContext()` for tab data
- [ ] Use `static actions` with `#` prefix handlers
- [ ] Use `.draggable` class and `data-item-id` for drag/drop
## References
- [ActorSheet V1 Tutorial](https://foundryvtt.wiki/en/development/guides/SD-tutorial/SD07-Extending-the-ActorSheet-class)
- [ApplicationV2 Guide](https://foundryvtt.wiki/en/development/api/applicationv2)
- [V2 Conversion Guide](https://foundryvtt.wiki/en/development/guides/applicationV2-conversion-guide)
- [ActorSheetV2 API](https://foundryvtt.com/api/classes/foundry.applications.sheets.ActorSheetV2.html)
- [Tabs in AppV2](https://foundryvtt.wiki/en/development/guides/Tabs-and-Templates/Tabs-in-AppV2)
---
**Last Updated:** 2026-01-04
**Status:** Production-Ready
**Maintainer:** ImproperSubset