Embedding API Intermediate

#DDQ2025-09 Embed Interactive Tableau Content w/ the Embedding API (Intermediate)

What is the Metadata API, how do you get started using it?

K

Kyle Massey

Author

2025-09-19T18:35:31
12 min read
preview.png

Welcome to the Intermediate DataDevQuest challenge for September 2025! This month we’re focusing on harnessing the power of the Tableau Embedding API (formerly, the JavaScript API), which allows you to make your Tableau content live seamlessly within your external application.

In the Beginner challenge, we learned how to embed Tableau content using the API and give the user a visual indication that the viz was loaded up and ready to go. Let’s take things even further now and add some back-and-forth interactivity!

An understanding of JavaScript basics will be very helpful in these challenges, but certainly isn’t required.

Note: We’re focusing on embedding content with the full API here, but there are certainly use cases where iframe embedding will be perfectly sufficient. Just know that this method will result in the loss of bi-directional interactivity.

Challenge Overview

Objective:

Add your content using the Embedding API and once it’s ready to go (via the FirstInteractive event listener) enable the user to interact with the viz and your page/application, having them pass events and data to one another.

This example shows you some of the things you might consider:

Why this challenge?

Embedding Tableau content in your application or website is a great way to harness the power of the tool, while also creating custom/bespoke experiences for your users. Imagine you have a central portal that allows users to perform several actions related to their duties, but you also want your Tableau insights to be front and center!

This challenge will help you learn how to meld your Tableau Content and your application/webpage into a singular experience where everything the user can interact with responds in harmony — allowing you to create truly bespoke user experiences that would never be possible with Tableau alone!

We can’t wait to see what you come up!

Learning Goals

  • Familiarize yourself with the Embedding API capabilities
  • Embed a Tableau View or Dashboard
  • Using JavaScript and event listeners, allow the user to interact with both the embedded viz and elements of your app which communicate with each other to make the magic happen!

Submission Guidelines

  • Source Code: Publish your project publicly in your GitHub profile
  • Add README: Include any setup instructions and describe how to run the program.
  • Video of Solution: Include a video of your solution in the README file. You can publish it on YouTube and embed the iframe, or save the video file in the repository’s root directory
  • Comments: Ensure your code is well-commented
  • Submission: Submit your challenge in the following forms

Additional Resources


Getting Started

1. Copy/paste the HTML and CSS examples below to files named index.html, styles.css and funcs.js (or use your own!)

2. You will need to serve up the page with an HTML server — if you’re using VS Code, this is super easy! Just right click on the file and select “Open with Live Server”

3. If this is your first time using the Embedding API, I strongly recommend you start with the Tableau Embedding Playground! It will show you how to setup your script imports and create your first EventListener — which you’ll need to complete this challenge.

Example code:

CODE
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark" class="html-height">

    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">

        <!-- Custom CSS -->
        <link rel="stylesheet" href="styles.css">

        <!-- Bootstrap CSS -->
        <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet"
            integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">

        <!-- Bootstrap Icons -->
        <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/bootstrap-icons.min.css">
        <title>DataDevQuest - DDQ2025-09 - Embedding: Intermediate</title>
    </head>

    <body class="body-height">

        <!-- Navbar -->
        <nav class="navbar bg-body-secondary">
            <div class="container-fluid">
                <a class="navbar-brand"><i class="bi bi-globe-americas-fill pe-3"></i>My Application or Website </a>

                <div id="loadingSpinner">
                    <div class="spinner-border spinner-border-sm loading-text" role="status">
                        <span class="visually-hidden">Loading...</span>

                    </div>
                    <span class="ms-2 loading-text">Tableau content is loading...</span>

                </div>

                <div class="d-flex align-items-center me-4">
                    <span class="double-em">
                        <i class="bi bi-person-fill me-2"></i>
                    </span>
                    <span>
                        Your Name
                    </span>
                </div>
            </div>
        </nav>

        <!-- Offcanvas (slide-in) for details pane -->
        <div class="offcanvas offcanvas-end" tabindex="-1" id="offcanvas" aria-labelledby="offcanvasLabel">
            <div class="offcanvas-header">
                <h5 class="offcanvas-title" id="offcanvasLabel">Hey! You selected some data!</h5>
                <button type="button" class="btn-close" onclick="hideDetailsPane()" aria-label="Close"></button>
            </div>
            <div class="offcanvas-body">
                <div class="w-100">
                    <div class="alert alert-info" role="alert">
                        Here are the states you selected on the map, along with profit ratios.
                    </div>

                </div>
                <div class="d-flex justify-content-end me-3 mb-4 w-100">
                    <button type="button" class=" w-100 btn btn-sm btn-outline-danger"
                        onclick="clearSelectedMarks(selectedWorksheet)">
                        CLEAR ALL SELECTIONS
                    </button>
                </div>
                <div id="stateList" class="list-group"></div>
            </div>
        </div>

        <!-- Tableau Viz Container -->
        <div class="container-fluid d-flex h-100 main-container-width">
            <!-- Tableau Viz Goes Here -->
        </div>

        <!--  Bootstrap Bundle with Popper -->
        <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"
            integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI" crossorigin="anonymous">
            </script>

        <!-- Tableau Embedding API -->
        <script type="module">
            import { TableauEventType, SelectionUpdateType } from 'https://public.tableau.com/javascripts/api/tableau.embedding.3.latest.js';

            // Get the viz object from the HTML web component
            const viz = document.querySelector('tableau-viz');


            // Wait for the viz to become interactive
            // Add 'FirstInteractive' listener here
            
            // Add event listener for mark selection change, etc. here
            // Listen for more events here
            

            // Make the Overview dashboard the active sheet
            const dashboard = await viz.workbook.activateSheetAsync('Overview'); // change to your dashboard name

            // Get the worksheet we want to use
            const worksheet = dashboard.worksheets.find((ws) => ws.name === 'Sale Map'); // change to your worksheet name
            selectedWorksheet = worksheet;

            // *** Insert your code below! ***
        </script>


        <!-- Custom JavaScript -->
        <script src="funcs.js">

        </script>
    </body>

</html>

NOTES:

  • I am using Bootstrap in the example above, a light-weight component library for building responsive pages with little overhead
  • Embedding is also possible with JS frameworks like React, Vue, Svelt, etc. but the steps will vary
    • If you generally work with one of these frameworks, you may need to research how importing external JS modules works for you and how they scope against the frameworks own functionality, i.e. YMMV!
CODE
.offcanvas-body {
    overflow-x: hidden;
}

.clickable-x {
    cursor: pointer;
    font-size: 1em;
}

.loading-spinner {
    width: 100vw;
    height: 100vh;
    position: absolute;
    top: 0;
    left: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    flex-direction: column;
    background-color: rgba(0, 0, 0, 0.85);
    z-index: 9999;
}

.loading-hidden {
    display: none;
}

.text-not-ready {
    color: orange;
    font-style: italic;
}

.text-ready {
    color: green;
    font-weight: bold;
}

.double-em {
    font-size: 2em;
}

.main-container-width {
    max-width: 85%;
}

.html-height {
    height: 100%;
}

.body-height {
    height: 90%
}

.loading-text {
    color: orange
}

.orange-text {
    color: orange;
}

.green-text {
    color: rgb(50, 255, 31);
}
CODE

var selectedMarksColumns = [];
var selectedMarksData = [];
var formattedData = [];
var selectedWorksheet = null;

// Hide the loading spinner in the navbar once the viz is loaded
function hideSpinner() {
    document.getElementById("loadingSpinner").style.visibility = "hidden";
}

// Format the selected marks data into an array of objects
function formatData() {
    this.formattedData = []

    selectedMarksData.forEach(row => {
        let formattedRow = {};
        selectedMarksColumns.forEach((col, index) => {
            formattedRow[col.fieldName] = row[index].value;
        });
        this.formattedData.push(formattedRow);
    });
}

// Explicitly hide the details pane
function hideDetailsPane() {
    let detailsPane = document.getElementById("offcanvas");
    detailsPane.classList.remove("show");
}

// Show or hide the details pane based on if there is data to show
function showHideDetailsPane() {
    let detailsPane = document.getElementById("offcanvas");

    if (this.formattedData.length === 0) {
        detailsPane.classList.remove("show");
    } else {
        detailsPane.classList.add("show");
    }
}


// Clear the selected marks from the worksheet
function clearSelectedMarks(worksheet) {
    // call Clear Marks method from API here
    selectedMarksColumns = [];
    selectedMarksData = [];
    formattedData = [];
    showHideDetailsPane();
}

// Format the profit ratio as a percentage with 2 decimal places
function formatProfitRatio(ratio) {
    if (ratio === null || ratio === undefined) {
        return "N/A";
    }
    return (ratio * 100).toFixed(2) + "%";
}

// Build the state list for the details pane
function buildStateDOMList(worksheet) {
    let stateList = document.getElementById("stateList");
    stateList.innerHTML = "";

    let containerDiv = document.createElement("div");
    containerDiv.classList.add("d-flex", "justify-space-between", "flex-column");

    this.formattedData.forEach(row => {
        let stateContainer = document.createElement("div");
        stateContainer.classList.add("d-flex", "justify-content-between", "align-items-center", "mb-2", "state-name", "me-3", "mb-2", "p-2", "border", "w-100", "rounded");
        let stateName = document.createElement("div");
        stateName.textContent = `${row['State']}`;

        let profitRatio = document.createElement("div");
        profitRatio.textContent = `${formatProfitRatio(row['AGG(Profit Ratio)'])}`;
        profitRatio.classList.add(row['AGG(Profit Ratio)'] >= 0 ? "green-text" : "orange-text");

        let ratioIcon = document.createElement("i");
        ratioIcon.classList.add("bi", `${row['AGG(Profit Ratio)'] >= 0 ? 'bi-arrow-up' : 'bi-arrow-down'}`, "ms-2");

        let removeIcon = document.createElement("i");
        removeIcon.id = `remove-${row['State']}`;
        removeIcon.classList.add("bi", "bi-x", "ms-3", "clickable-x", 'text-white');
        removeIcon.title = "Remove from selection";

        // Add click event to the remove icon to deselect the state
        removeIcon.onclick = () => {
            selectStateMarks(worksheet, filteredStateList(row['State']));
        }

        profitRatio.appendChild(ratioIcon);
        profitRatio.appendChild(removeIcon);

        stateContainer.appendChild(stateName);
        stateContainer.appendChild(profitRatio);
        containerDiv.appendChild(stateContainer);
    });

    // If no states are selected, show a message
    if (this.formattedData.length === 0) {
        let li = document.createElement("li");
        li.classList.add("list-group-item");
        li.textContent = "No states selected";
        stateList.appendChild(li);
    }

    stateList.appendChild(containerDiv);
}

// Return a list of states excluding the one to remove
function filteredStateList(stateToRemove) {
    let filteredObjs = this.formattedData.filter(row => row['State'] !== stateToRemove);

    return filteredObjs.map(row => row['State']);
}

// Select marks in the worksheet based on a list of states
function selectStateMarks(worksheet, states) {
    if (states.length === 0) {
        // Call Clear Selected Marks method from API here
        return;
    }

    // Call Select Marks method from API here

}

NOTES:

  • The examples above are written in very ‘vanilla’ JavaScript for clarity, be sure to adapt them to your needs — especially if using a framework like React, Vue, etc.
  • Refer to the Beginner Challenge for instructions on serving your page locally
  • Keep the Embedding API Reference handy as there are many options available. Suggested methods to explore first:
    • activateSheetAsync
    • getSelectedMarksAsync
    • clearSelectedMarksAsync
    • selectMarksByValueAsync

CODE
// CSS Styles
.loading-spinner {
    width: 100vw;    height: 100vh;
    position: absolute;
    top: 0;
    left: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    flex-direction: column;
    background-color: rgba(0, 0, 0, 0.85);
    z-index: 9999;
}

.loading-hidden {
    display: none;
}

.text-not-ready {
    color: orange;
    font-style: italic;
}

.text-ready {
    color: green;
    font-weight: bold;
}

.double-em {
    font-size: 2em;
}

.main-container-width {
    max-width: 85%;
}

.html-height {
    height: 100%;
}

.body-height {
    height: 90%
}

.loading-text {
    color: orange
}

Challenge

Create a small app/page with embedded Tableau Content and two-way interactivity between the components of the page and the embedded viz!


Full JavaScript Solution

CODE
var selectedMarksColumns = [];
var selectedMarksData = [];
var formattedData = [];
var selectedWorksheet = null;

// Hide the loading spinner in the navbar once the viz is loaded
function hideSpinner() {
    document.getElementById("loadingSpinner").style.visibility = "hidden";
}

// Format the selected marks data into an array of objects
function formatData() {
    this.formattedData = []

    selectedMarksData.forEach(row => {
        let formattedRow = {};
        selectedMarksColumns.forEach((col, index) => {
            formattedRow[col.fieldName] = row[index].value;
        });
        this.formattedData.push(formattedRow);
    });
}

// Explicitly hide the details pane
function hideDetailsPane() {
    let detailsPane = document.getElementById("offcanvas");
    detailsPane.classList.remove("show");
}

// Show or hide the details pane based on if there is data to show
function showHideDetailsPane() {
    let detailsPane = document.getElementById("offcanvas");

    if (this.formattedData.length === 0) {
        detailsPane.classList.remove("show");
    } else {
        detailsPane.classList.add("show");
    }
}


// Clear the selected marks from the worksheet
function clearSelectedMarks(worksheet) {
    worksheet.clearSelectedMarksAsync();
    selectedMarksColumns = [];
    selectedMarksData = [];
    formattedData = [];
    showHideDetailsPane();
}

// Format the profit ratio as a percentage with 2 decimal places
function formatProfitRatio(ratio) {
    if (ratio === null || ratio === undefined) {
        return "N/A";
    }
    return (ratio * 100).toFixed(2) + "%";
}

// Build the state list for the details pane
function buildStateDOMList(worksheet) {
    let stateList = document.getElementById("stateList");
    stateList.innerHTML = "";

    let containerDiv = document.createElement("div");
    containerDiv.classList.add("d-flex", "justify-space-between", "flex-column");

    this.formattedData.forEach(row => {
        let stateContainer = document.createElement("div");
        stateContainer.classList.add("d-flex", "justify-content-between", "align-items-center", "mb-2", "state-name", "me-3", "mb-2", "p-2", "border", "w-100", "rounded");
        let stateName = document.createElement("div");
        stateName.textContent = `${row['State']}`;

        let profitRatio = document.createElement("div");
        profitRatio.textContent = `${formatProfitRatio(row['AGG(Profit Ratio)'])}`;
        profitRatio.classList.add(row['AGG(Profit Ratio)'] >= 0 ? "green-text" : "orange-text");

        let ratioIcon = document.createElement("i");
        ratioIcon.classList.add("bi", `${row['AGG(Profit Ratio)'] >= 0 ? 'bi-arrow-up' : 'bi-arrow-down'}`, "ms-2");

        let removeIcon = document.createElement("i");
        removeIcon.id = `remove-${row['State']}`;
        removeIcon.classList.add("bi", "bi-x", "ms-3", "clickable-x", 'text-white');
        removeIcon.title = "Remove from selection";

        // Add click event to the remove icon to deselect the state
        removeIcon.onclick = () => {
            selectStateMarks(worksheet, filteredStateList(row['State']));
        }

        profitRatio.appendChild(ratioIcon);
        profitRatio.appendChild(removeIcon);

        stateContainer.appendChild(stateName);
        stateContainer.appendChild(profitRatio);
        containerDiv.appendChild(stateContainer);
    });

    // If no states are selected, show a message
    if (this.formattedData.length === 0) {
        let li = document.createElement("li");
        li.classList.add("list-group-item");
        li.textContent = "No states selected";
        stateList.appendChild(li);
    }

    stateList.appendChild(containerDiv);
}

// Return a list of states excluding the one to remove
function filteredStateList(stateToRemove) {
    let filteredObjs = this.formattedData.filter(row => row['State'] !== stateToRemove);

    return filteredObjs.map(row => row['State']);
}

// Select marks in the worksheet based on a list of states
function selectStateMarks(worksheet, states) {
    if (states.length === 0) {
        worksheet.clearSelectedMarksAsync();
        return;
    }

    worksheet.selectMarksByValueAsync([{
        fieldName: 'State',
        value: [...states]
    }], "select-replace");

}

Who am I?

I am Kyle Massey, 4x Tableau DataDev Ambassador and Visionary. I am a co-founder of DataDevQuest. I get my kicks with data, automation, music and my pets!

Have questions or feedback about this challenge? I’d love to hear from you: LinkedIn / X (fka Twitter).


Related Challenges

Continue learning about the: Embedding API