Why this chapter? In real client work, you rarely get to build a component once and never touch it again — the next project asks for "the same modal, but with a different footer" or "the same list, but each row needs a custom action." Slots are what let you say yes without duplicating the component. What is Odoo trying to solve with this? Odoo's own UI (kanban cards, dialogs, list rows) has to stay flexible across wildly different models and use cases without an explosion of near-identical one-off widgets. Slots let a single component own the structure while callers own the content. Real-world application: A reusable confirmation-dialog wrapper used across a dozen addons for the same client, or a generic table component whose cell rendering changes per view without forking the table itself.
So far, our components have been self-contained units. A Card component always has a title and content. A PartnerList always renders a list of partners in a predefined format. But what if we want to create flexible, reusable components that can adapt to different use cases?
Imagine you're building a generic Modal component. The modal itself provides the background overlay, the white container, the positioning, and the close button functionality. But the content inside the modal—whether it's a confirmation message, a complex form, a list of images, or a video player—should be completely up to the parent component that uses it.
This is where composition becomes essential, and OWL's mechanism for achieving this is slots. Slots are placeholders in a child component's template that parent components can fill with their own content. Think of them as "content holes" that parents can plug with custom markup, creating infinite possibilities from a single, well-designed component.
This pattern is fundamental to building scalable component libraries and is used extensively throughout Odoo's interface.
Understanding the Problem: Why We Need Composition
Before diving into slots, let's understand the problem they solve with a concrete example.
The Rigid Approach (Without Slots)
Imagine we create a ProductCard component that displays product information:
// ProductCard.js
import { Component, xml } from "@odoo/owl";
export class ProductCard extends Component {
static template = xml`
<div class="product-card">
<h3 t-esc="props.product.name"/>
<p t-esc="props.product.description"/>
<div class="price">$<t t-esc="props.product.price"/></div>
<button class="btn btn-primary">Add to Cart</button>
</div>
`;
}
This works fine for a basic product listing. But what happens when you need: - A version with an image thumbnail? - A version with customer reviews? - A version with quantity selectors? - A version with discount badges?
Without composition, you'd end up creating multiple similar components (ProductCardWithImage, ProductCardWithReviews, etc.) or adding dozens of conditional props (showImage, showReviews, showQuantity). Both approaches lead to rigid, hard-to-maintain code.
The Flexible Approach (With Slots)
With slots, you create a single, flexible ProductCard component that provides the structure and styling, while allowing parents to inject custom content:
// FlexibleProductCard.js
export class FlexibleProductCard extends Component {
static template = xml`
<div class="product-card">
<t t-slot="header"/>
<h3 t-esc="props.product.name"/>
<t t-slot="content"/>
<div class="price">$<t t-esc="props.product.price"/></div>
<t t-slot="actions"/>
</div>
`;
}
Now parents can customize each section:
<!-- In parent template -->
<FlexibleProductCard product="product">
<t t-set-slot="header">
<img t-att-src="product.imageUrl" class="product-thumbnail"/>
<span class="badge badge-sale" t-if="product.onSale">SALE!</span>
</t>
<t t-set-slot="content">
<p t-esc="product.description"/>
<div class="reviews">
<t t-foreach="product.reviews.slice(0, 3)" t-as="review" t-key="review.id">
<div class="review"><t t-esc="review.text"/></div>
</t>
</div>
</t>
<t t-set-slot="actions">
<input type="number" t-model="state.quantity" min="1" max="10"/>
<button class="btn btn-primary" t-on-click="addToCart">
Add <t t-esc="state.quantity"/> to Cart
</button>
</t>
</FlexibleProductCard>
This single component can now handle countless variations without becoming bloated or complex.
The Default Slot: Your First Step into Composition
The most common use case for slots is when you need a simple content placeholder. This is handled by the default slot—a single slot that doesn't need to be named.
Creating a Modal Component
Let's build a reusable Modal component step by step:
1. Define the Modal Structure (modal.xml)
<t t-name="my_module.Modal" owl="1">
<div class="modal-backdrop" t-on-click="closeIfClickOutside">
<div class="modal-dialog" t-on-click.stop="">
<div class="modal-content">
<!-- Modal Header -->
<div class="modal-header">
<h5 class="modal-title" t-esc="props.title or 'Modal'"/>
<button type="button"
class="btn-close"
t-on-click="close"
aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
<!-- Modal Body - This is where parent content goes -->
<div class="modal-body">
<t t-slot="default"/>
</div>
<!-- Modal Footer (if provided) -->
<div class="modal-footer" t-if="props.slots.footer">
<t t-slot="footer"/>
</div>
</div>
</div>
</div>
</t>
2. The Modal Component Class (modal.js)
import { Component, onWillUnmount } from "@odoo/owl";
export class Modal extends Component {
static template = "my_module.Modal";
setup() {
// Close modal when Escape key is pressed
this.onKeydown = this.onKeydown.bind(this);
document.addEventListener('keydown', this.onKeydown);
onWillUnmount(() => {
document.removeEventListener('keydown', this.onKeydown);
});
}
onKeydown(event) {
if (event.key === 'Escape') {
this.close();
}
}
close() {
if (this.props.onClose) {
this.props.onClose();
}
}
closeIfClickOutside(event) {
// Only close if clicking the backdrop, not the modal content
if (event.target === event.currentTarget) {
this.close();
}
}
}
3. Using the Modal from a Parent Component
<t t-name="my_module.Dashboard" owl="1">
<div class="dashboard">
<h1>My Dashboard</h1>
<button class="btn btn-primary" t-on-click="openConfirmModal">
Delete All Data
</button>
<!-- Confirmation Modal -->
<t t-if="state.showConfirmModal">
<Modal title="Confirm Deletion" onClose="closeConfirmModal">
<!-- Everything between these tags goes into the default slot -->
<div class="alert alert-danger">
<h4>Warning</h4>
<p>This action will permanently delete all your data. This cannot be undone.</p>
<p>Type <strong>DELETE</strong> to confirm:</p>
<input type="text"
class="form-control"
t-model="state.confirmText"
placeholder="Type DELETE here"/>
</div>
<!-- Footer slot for custom buttons -->
<t t-set-slot="footer">
<button class="btn btn-secondary" t-on-click="closeConfirmModal">
Cancel
</button>
<button class="btn btn-danger"
t-att-disabled="state.confirmText !== 'DELETE'"
t-on-click="performDeletion">
Delete Everything
</button>
</t>
</Modal>
</t>
</div>
</t>
4. The Dashboard Component Logic
import { Component, useState } from "@odoo/owl";
export class Dashboard extends Component {
static template = "my_module.Dashboard";
static components = { Modal };
setup() {
this.state = useState({
showConfirmModal: false,
confirmText: ""
});
}
openConfirmModal() {
this.state.showConfirmModal = true;
this.state.confirmText = "";
}
closeConfirmModal() {
this.state.showConfirmModal = false;
}
performDeletion() {
if (this.state.confirmText === 'DELETE') {
// Perform the actual deletion
console.log("Deleting all data...");
this.closeConfirmModal();
}
}
}
Key Benefits of This Approach
1. Reusability: The same Modal component can display confirmation dialogs, forms, image galleries, or any other content.
2. Separation of Concerns: The modal handles positioning, backdrop, keyboard events, and styling. The parent handles the specific content and business logic.
3. Maintainability: Modal behavior is centralized. Fixing a bug or adding a feature (like animation) benefits all modals across your application.
Named Slots: Multiple Content Areas
When you need more control over where different pieces of content go, named slots allow you to define multiple, distinct areas for content injection.
Building a Flexible Page Layout
Let's create a PageLayout component that provides a standard page structure:
1. The PageLayout Template (page_layout.xml)
<t t-name="my_module.PageLayout" owl="1">
<div class="page-container">
<!-- Navigation Area -->
<nav class="page-navigation" t-if="props.slots.navigation">
<t t-slot="navigation"/>
</nav>
<!-- Header Section -->
<header class="page-header">
<t t-slot="header">
<!-- Default header content if none provided -->
<h1 t-esc="props.title or 'Page Title'"/>
</t>
</header>
<!-- Sidebar (if provided) -->
<aside class="page-sidebar" t-if="props.slots.sidebar">
<t t-slot="sidebar"/>
</aside>
<!-- Main Content Area -->
<main class="page-content" t-att-class="{ 'with-sidebar': props.slots.sidebar }">
<t t-slot="default">
<!-- Default content if none provided -->
<p>No content provided.</p>
</t>
</main>
<!-- Action Bar -->
<div class="page-actions" t-if="props.slots.actions">
<t t-slot="actions"/>
</div>
<!-- Footer -->
<footer class="page-footer" t-if="props.slots.footer">
<t t-slot="footer"/>
</footer>
</div>
</t>
2. Using Named Slots from Multiple Parents
Example 1: A Settings Page
<PageLayout title="User Settings">
<t t-set-slot="navigation">
<ul class="nav-menu">
<li><a href="#profile">Profile</a></li>
<li><a href="#security">Security</a></li>
<li><a href="#notifications">Notifications</a></li>
</ul>
</t>
<t t-set-slot="sidebar">
<div class="settings-sidebar">
<h3>Quick Settings</h3>
<label>
<input type="checkbox" t-model="state.darkMode"/>
Dark Mode
</label>
<label>
<input type="checkbox" t-model="state.notifications"/>
Email Notifications
</label>
</div>
</t>
<!-- Main content goes to default slot -->
<div class="settings-content">
<h2>Profile Settings</h2>
<form t-on-submit.prevent="saveSettings">
<div class="form-group">
<label>Full Name</label>
<input type="text" t-model="state.user.name" class="form-control"/>
</div>
<div class="form-group">
<label>Email</label>
<input type="email" t-model="state.user.email" class="form-control"/>
</div>
</form>
</div>
<t t-set-slot="actions">
<button class="btn btn-secondary" t-on-click="resetSettings">Reset</button>
<button class="btn btn-primary" t-on-click="saveSettings">Save Changes</button>
</t>
</PageLayout>
Example 2: A Product Catalog Page
<PageLayout title="Product Catalog">
<t t-set-slot="header">
<div class="catalog-header">
<h1>Our Products</h1>
<div class="search-bar">
<input type="text"
placeholder="Search products..."
t-model="state.searchQuery"
class="form-control"/>
</div>
</div>
</t>
<t t-set-slot="sidebar">
<div class="filters">
<h3>Filters</h3>
<div class="filter-group">
<label>Category</label>
<select t-model="state.selectedCategory" class="form-control">
<option value="">All Categories</option>
<t t-foreach="state.categories" t-as="category" t-key="category.id">
<option t-att-value="category.id" t-esc="category.name"/>
</t>
</select>
</div>
<div class="filter-group">
<label>Price Range</label>
<input type="range" t-model="state.maxPrice" min="0" max="1000"/>
<span>Up to $<t t-esc="state.maxPrice"/></span>
</div>
</div>
</t>
<!-- Product grid in main content -->
<div class="product-grid">
<t t-foreach="filteredProducts" t-as="product" t-key="product.id">
<ProductCard product="product"/>
</t>
</div>
<t t-set-slot="footer">
<div class="pagination">
<button t-att-disabled="state.currentPage === 1"
t-on-click="previousPage">
Previous
</button>
<span>Page <t t-esc="state.currentPage"/> of <t t-esc="state.totalPages"/></span>
<button t-att-disabled="state.currentPage === state.totalPages"
t-on-click="nextPage">
Next
</button>
</div>
</t>
</PageLayout>
Understanding Slot Detection
Notice the t-if="props.slots.sidebar" pattern in our template. OWL automatically provides a props.slots object that tells you which slots the parent has filled:
<!-- Only render the sidebar container if parent provided sidebar content -->
<aside class="page-sidebar" t-if="props.slots.sidebar">
<t t-slot="sidebar"/>
</aside>
<!-- Apply different CSS class based on whether sidebar exists -->
<main class="page-content" t-att-class="{ 'with-sidebar': props.slots.sidebar }">
<t t-slot="default"/>
</main>
This allows your component to adapt its layout dynamically based on what content the parent provides.
Scoped Slots: Sharing Data Between Child and Parent
The most advanced slot pattern is scoped slots, where the child component passes data up to the parent's slot content. This enables incredibly flexible components that handle data logic while giving parents complete control over presentation.
Building a Generic Data Table
Let's create a DataTable component that handles sorting, filtering, and pagination, but allows parents to completely customize how each row is rendered:
1. The DataTable Component (data_table.js)
import { Component, useState } from "@odoo/owl";
export class DataTable extends Component {
static template = "my_module.DataTable";
setup() {
this.state = useState({
sortField: null,
sortDirection: 'asc',
currentPage: 1,
pageSize: this.props.pageSize || 10,
searchQuery: ""
});
}
get filteredData() {
let data = this.props.data || [];
// Apply search filter
if (this.state.searchQuery) {
const query = this.state.searchQuery.toLowerCase();
data = data.filter(item =>
Object.values(item).some(value =>
String(value).toLowerCase().includes(query)
)
);
}
// Apply sorting
if (this.state.sortField) {
data = [...data].sort((a, b) => {
const aVal = a[this.state.sortField];
const bVal = b[this.state.sortField];
if (aVal < bVal) return this.state.sortDirection === 'asc' ? -1 : 1;
if (aVal > bVal) return this.state.sortDirection === 'asc' ? 1 : -1;
return 0;
});
}
return data;
}
get paginatedData() {
const start = (this.state.currentPage - 1) * this.state.pageSize;
const end = start + this.state.pageSize;
return this.filteredData.slice(start, end);
}
get totalPages() {
return Math.ceil(this.filteredData.length / this.state.pageSize);
}
sortBy(field) {
if (this.state.sortField === field) {
this.state.sortDirection = this.state.sortDirection === 'asc' ? 'desc' : 'asc';
} else {
this.state.sortField = field;
this.state.sortDirection = 'asc';
}
this.state.currentPage = 1; // Reset to first page when sorting
}
nextPage() {
if (this.state.currentPage < this.totalPages) {
this.state.currentPage++;
}
}
previousPage() {
if (this.state.currentPage > 1) {
this.state.currentPage--;
}
}
}
2. The DataTable Template (data_table.xml)
<t t-name="my_module.DataTable" owl="1">
<div class="data-table-container">
<!-- Search Bar -->
<div class="table-controls">
<input type="text"
class="form-control search-input"
placeholder="Search..."
t-model="state.searchQuery"/>
</div>
<!-- Table Header (if provided) -->
<div class="table-header" t-if="props.slots.header">
<t t-slot="header"
sortBy="sortBy"
sortField="state.sortField"
sortDirection="state.sortDirection"/>
</div>
<!-- Table Body -->
<div class="table-body">
<t t-if="paginatedData.length === 0">
<div class="empty-state">
<t t-slot="empty">
<p>No data available</p>
</t>
</div>
</t>
<t t-else="">
<t t-foreach="paginatedData" t-as="item" t-key="item.id">
<!-- Pass item data and utilities to parent slot -->
<t t-slot="row"
item="item"
index="item_index"
isEven="item_index % 2 === 0"/>
</t>
</t>
</div>
<!-- Pagination -->
<div class="table-pagination" t-if="totalPages > 1">
<button class="btn btn-sm btn-secondary"
t-att-disabled="state.currentPage === 1"
t-on-click="previousPage">
Previous
</button>
<span class="pagination-info">
Page <t t-esc="state.currentPage"/> of <t t-esc="totalPages"/>
(<t t-esc="filteredData.length"/> total items)
</span>
<button class="btn btn-sm btn-secondary"
t-att-disabled="state.currentPage === totalPages"
t-on-click="nextPage">
Next
</button>
</div>
</div>
</t>
3. Using the Scoped Data Table
Now parents can use this powerful table while having complete control over how data is displayed:
<!-- In a parent component -->
<DataTable data="state.customers" pageSize="5">
<!-- Custom sortable header -->
<t t-set-slot="header" t-slot-scope="headerProps">
<div class="table-header-row">
<div class="header-cell sortable"
t-on-click="() => headerProps.sortBy('name')"
t-att-class="{
'sort-asc': headerProps.sortField === 'name' && headerProps.sortDirection === 'asc',
'sort-desc': headerProps.sortField === 'name' && headerProps.sortDirection === 'desc'
}">
Customer Name
</div>
<div class="header-cell sortable"
t-on-click="() => headerProps.sortBy('email')">
Email
</div>
<div class="header-cell sortable"
t-on-click="() => headerProps.sortBy('totalOrders')">
Total Orders
</div>
<div class="header-cell">Actions</div>
</div>
</t>
<!-- Custom row rendering -->
<t t-set-slot="row" t-slot-scope="rowProps">
<div class="table-row" t-att-class="{ 'even': rowProps.isEven }">
<div class="cell">
<img t-att-src="rowProps.item.avatar" class="avatar-sm"/>
<strong t-esc="rowProps.item.name"/>
<span t-if="rowProps.item.isVip" class="badge badge-gold">VIP</span>
</div>
<div class="cell">
<a t-att-href="`mailto:${rowProps.item.email}`" t-esc="rowProps.item.email"/>
</div>
<div class="cell">
<span class="order-count" t-esc="rowProps.item.totalOrders"/>
<small t-if="rowProps.item.lastOrderDate">
(Last: <t t-esc="formatDate(rowProps.item.lastOrderDate)"/>)
</small>
</div>
<div class="cell actions">
<button class="btn btn-sm btn-primary"
t-on-click="() => this.editCustomer(rowProps.item)">
Edit
</button>
<button class="btn btn-sm btn-danger"
t-on-click="() => this.deleteCustomer(rowProps.item)">
Delete
</button>
</div>
</div>
</t>
<!-- Custom empty state -->
<t t-set-slot="empty">
<div class="text-center p-4">
<h3>No customers found</h3>
<p>Try adjusting your search criteria or add your first customer.</p>
<button class="btn btn-primary" t-on-click="openAddCustomerModal">
Add First Customer
</button>
</div>
</t>
</DataTable>
Understanding Scoped Slot Props
The key to scoped slots is the t-slot-scope directive on <t t-set-slot>:
<!-- Child passes data as attributes on the t-slot call -->
<t t-slot="row"
item="item"
index="item_index"
isEven="item_index % 2 === 0"/>
<!-- Parent names the scope object with t-slot-scope and receives everything in it -->
<t t-set-slot="row" t-slot-scope="rowProps">
<!-- rowProps contains: { item: {...}, index: 0, isEven: true } -->
<div>Item name: <t t-esc="rowProps.item.name"/></div>
</t>
This pattern allows the child component to provide both data and utility functions to the parent's rendering logic.
Rendering Outside the Tree: t-portal
Slots let a parent control what a child renders. t-portal solves a different problem: it lets a component render where in the actual DOM its content appears, independent of where the component sits in OWL's component tree.
Why would you want that? Think of a modal dialog or a tooltip triggered from deep inside a page—say, from a table row inside a card inside a sidebar. If the modal renders normally, it inherits whatever overflow: hidden or z-index stacking context its ancestors set up, which can clip it or bury it behind other content. The fix web developers have used for years is to render that modal's DOM as a direct child of <body>, sidestepping every ancestor's styling—while still keeping it a normal, fully reactive OWL component logically owned by its parent.
t-portal does exactly that: the component's logical position in the tree (props, state, lifecycle) doesn't change, only where its DOM ends up.
class Tooltip extends Component {
static template = xml`
<span t-ref="anchor" t-on-mouseenter="() => state.visible = true">
<t t-esc="props.label"/>
</span>
<div t-if="state.visible" t-portal="'body'" class="tooltip-content">
<t t-esc="props.text"/>
</div>`;
setup() {
this.state = useState({ visible: false });
}
}
The t-portal attribute takes a CSS selector, as a string, for where the content should be attached—here, 'body'. OWL inserts an empty placeholder node at the tooltip's original location to keep track of things internally, but the actual .tooltip-content element is appended to <body>, free of any clipping or stacking issues from its logical ancestors.
Reach for t-portal specifically for modals, tooltips, and dropdown menus—content that needs to visually escape its parent's layout while remaining, from OWL's point of view, an ordinary part of the component that created it.
Best Practices for Slot-Based Architecture
1. Design Slots with Purpose
Each slot should have a clear, single responsibility:
- header: Page titles, navigation, search bars
- actions: Buttons, controls, action menus
- footer: Pagination, totals, help text
- default: Main content area
Avoid generic slots like slot1, slot2 that don't convey meaning.
2. Provide Sensible Defaults
Always provide default content for slots when it makes sense:
<t t-slot="header">
<!-- Default header if parent doesn't provide one -->
<h1 t-esc="props.title or 'Untitled Page'"/>
</t>
This makes your component work out-of-the-box while still being customizable.
3. Document Your Slot API
When creating reusable components, document what slots are available and what props scoped slots provide:
/**
* DataTable Component
*
* Slots:
* - header: Table column headers (props: { sortBy, sortField, sortDirection })
* - row: Individual row rendering (props: { item, index, isEven })
* - empty: Shown when no data available
*
* Props:
* - data: Array of objects to display
* - pageSize: Number of items per page (default: 10)
*/
export class DataTable extends Component {
// ...
}
4. Use Conditional Slot Rendering
Adapt your component's layout based on which slots are provided:
<!-- Adjust main content width based on sidebar presence -->
<main t-att-class="{
'col-12': !props.slots.sidebar,
'col-9': props.slots.sidebar
}">
<t t-slot="default"/>
</main>
5. Combine Slots with Props for Maximum Flexibility
Some content areas might be simple enough for props, while others need the full power of slots:
// Simple text can be a prop
static props = {
title: { type: String, optional: true },
subtitle: { type: String, optional: true },
// Complex content uses slots
};
<div class="card">
<!-- Simple content via props -->
<h3 t-if="props.title" t-esc="props.title"/>
<p t-if="props.subtitle" t-esc="props.subtitle"/>
<!-- Complex content via slots -->
<t t-slot="default"/>
</div>
Common Pitfalls and How to Avoid Them
1. Overusing Slots
Not everything needs to be a slot. If content rarely changes, a prop might be simpler:
// Instead of this...
<Modal>
<t t-set-slot="title">Save Changes?</t>
</Modal>
// Consider this...
<Modal title="Save Changes?">
2. Forgetting to Handle Empty Slots
Always check if a slot has content before rendering its container:
<!-- Good: Only render footer if content provided -->
<footer t-if="props.slots.footer" class="modal-footer">
<t t-slot="footer"/>
</footer>
<!-- Bad: Empty footer div if no content -->
<footer class="modal-footer">
<t t-slot="footer"/>
</footer>
3. Complex Logic in Templates
Keep slot logic simple. Move complex computations to getters or methods:
// Good: Logic in component
get shouldShowSidebar() {
return this.props.slots.sidebar && !this.props.hideSidebar;
}
<!-- Simple template -->
<aside t-if="shouldShowSidebar">
<t t-slot="sidebar"/>
</aside>
Mastering slots transforms how you think about component design. Instead of creating rigid, single-purpose components, you build flexible foundations that adapt to countless use cases. This approach is essential for building maintainable, scalable Odoo applications and is used throughout the Odoo codebase for maximum reusability and customization.
In the next chapter, we'll explore how to manage state that needs to be shared across components that aren't directly related in the component tree.
TL;DR: Slots let a parent inject content (default, named, or scoped) into a child's structure, so you build one flexible component instead of many rigid, near-identical ones.
Try it yourself: This chapter's example is available as an installable Odoo 19 addon: simplifyit_owl_book_ch14_ex1. Installation instructions are in the repository README.
Exercises
- Modal with two slots. Build a
Modalcomponent with a default slot for the body and a namedfooterslot. Render it once with only a body, and once with both body and footer, confirming the footer area disappears cleanly when unused (see "Forgetting to Handle Empty Slots" above). - Scoped slot practice. Extend the
DataTablefrom this chapter so the parent's slot receives not just the rowrecord, but also the row'sindex. Use it in the parent template to render alternating row colors. - Slot vs. prop. Take a component you've built with a slot and ask: could this content instead be a simple prop? Rewrite it as a prop if so, and write one sentence on which version is easier to use from the parent's side.
What's Next?
We've covered how content flows between components that are directly related as parent and child. Chapter 15 tackles the harder case: state shared between components that aren't related at all in the tree, using a global store.