Let’s be social.

Search
  >  Design   >  We Have Crunchy Roll at Home: Three Themes, One Interface
Read Time: 7 minutes

In my last post, We Have Crunchyroll at Home, I unpacked the Python script that powers the Rapp Anime Invitational Library (RAIL System)—a personal streaming platform for my growing anime collection. That backend handles the heavy lifting: scraping metadata from TMDb, organizing files, and serving up everything locally.

But let’s be honest, the functionality is only half the fun.

The frontend is where the feel happens. It’s where style meets usability, and nostalgia meets design systems. In this article, I walk through the interface design for RAIL: how I set UX goals, built theme-based visual styles, and implemented everything in HTML, CSS, and JavaScript.

1. An Experience That Doesn’t Get in the Way

I started with some basic non-negotiables. Even though this is a personal project, I wanted the experience to feel like a product you’d use daily.to define what makes a great streaming UI.

UX Priorities

  • Immediate content access – Get to watching with as few clicks as possible.
  • Intuitive navigation – Sorting, filtering, and searching should feel frictionless.
  • Responsive design – Everything needs to scale gracefully from desktop to mobile.
  • Stylish, but useful – Visual polish should enhance—not compete with—content.

Points of Reference

I took the time to research and learn some lessons from the usual suspects.

  • Crunchyroll for anime-first UX (but dinged for some sluggishness).
  • Netflix for robust structure (but clutter-prone).
  • HBO Max for motion and finish.
  • Toonami for its cyber-heroic energy.

2. Embracing Multiple Aesthetics: Theme Switching

Instead of 1 core style, I setup the build so that the user can create and choose from a number of CSS themes.

Default Theme Crunchy

This theme mimics the punchy brightness of Crunchyroll, swapping out cyan and silver for bold orange and warm gradients. Borders go rounded, backgrounds lighten, and everything feels more playful.

Wireframe for the landing page of the RAIL system with the Crunchy theme applied.
Wireframe for the anime details page of the RAIL system with the Crunchy theme applied.

Toonami Theme

A sleek, futuristic mode with a tech-forward edge. This is the core vibe of the RAIL System—sharp angles, dark tones, and glowing neon accents. Paired with the Rajdhani typeface and high-contrast UI elements, the Toonami theme feels like flipping on [adult swim] at midnight.

Wireframe for the landing page of the RAIL system with the Toonami theme applied.
Wireframe for the categories page of the RAIL system with the Toonami theme applied.

Cartoon Cartoon Theme

Still in development, but this one’s inspired by the high-contrast, color-blocked layouts of late-90s Cartoon Network. Think bold shapes, big typography, and playful motion. The idea is to create a UI that leans into nostalgia without being chaotic.

Built in Theme Switcher

Users can flip between styles with a click. The interface instantly swaps out stylesheets (style-toonami.css, style-crunchy.css, etc.) and persists the choice.

Here’s what the theme switcher modal looks like in the DOM:

<!-- Settings Modal -->
    <div id="settings_modal" class="modal hidden">
        <div class="modal-content">
            <div class="modal-header">
                <h2>Settings</h2>
                <span class="close-modal" id="save_settings">&times;</span>
            </div>

            <div class="modal-body">
                <div class="theme-switch">
                    <h2>Choose a Theme</h2>

                    <div class="theme-options">
                        <div class="theme-option" data-theme="crunchy">
                            <div class="theme-preview crunchy-preview"></div>
                            <span>Crunchy</span>
                        </div>
                        <div class="theme-option" data-theme="toonami">
                            <div class="theme-preview toonami-preview"></div>
                            <span>Toonami</span>
                        </div>
                        <div class="theme-option" data-theme="cartoon-network">
                            <div class="theme-preview cn-preview"></div>
                            <span>Cartoons</span>
                        </div>
                    </div>
                </div>

                <h2>General Settings</h2>

                <div class="setting-item conditional">
                    <div class="setting-label">Bumpers <i class="fa-solid fa-circle-info" data-toggle="tooltip" title="No one nos"></i></div>
                    <label class="content-switch">
                        <input type="checkbox" id="settings_bumper_toggle">
                        <span class="slider">
                            <span class="slider-text"> </span>
                        </span>
                    </label>
                </div>

                <div class="setting-item">
                    <div class="setting-label">UI Sounds</div>
                    <label class="content-switch">
                        <input type="checkbox" id="settings_sounds_toggle">
                        <span class="slider">
                            <span class="slider-text"> </span>
                        </span>
                    </label>
                </div>
            </div>

            <div class="modal-footer">
                <!-- <button class="primary" id="save_settings">Close</button> -->
            </div>
        </div>
    </div>

Each theme is more than a color swap—it changes layout, spacing, typography, and motion behaviors.

3. Structuring the Interface

The HTML structure stays consistent across themes, allowing the CSS to do the heavy lifting. Here’s a snapshot of the core layout:

<header class="container">
    <div class="left">
            <span class="left logo">
                <img src="assets/rail-logo.svg" alt="Logo for The RAIL Streaming System"/>
            </span>

            <span class="left">
                <h1>R.A.I.L.</h1>
                <h2>Rapp Anime Invitational Library</h2>
            </span>
        </div>

        <div class="right filters">
            <label class="right content-switch hidden" title="Adult content">
                <input type="checkbox" id="adult_toggle" checked>
                <span class="slider">
                    <span class="slider-text">18+</span>
                </span>
            </label>

            <a href="anime-search.html">
                <i class="fa fa-search fa-2x" id="search_button"></i>
            </a>

            <i class="fa fa-gear fa-2x" id="settings_button"></i>
        </div>
 </header>

    <div class="clear"></div>

    <!-- Featured Title -->
    <section class="clear" id="featured">
        <div class="anime-information" style="position: relative; z-index: 2;">
            <div id="anime_producer"></div>
            <h1 id="anime_title"></h1>
            <p id="anime_description"></p>
            
            <table cellpadding="0" cellspacing="0" width="100%">
                <tr>
                    <td id="anime_year" width="25%">relesed in</td>
                    <td id="anime_runtime" width="20%"></td>
                    <td id="anime_genre"></td>
                </tr>
            </table>

            <div class="user-actions">
                <button class="primary">View Anime</button>
            </div>
        </div>

        <div id="featured_poster" class="modern-vignette"></div>
    </section>

    <!-- Anime List -->
    <section class="clear">
        <div class="container metadata">
            <div class="left anime_count"><!-- Dynamically count titles --></div>

            <div class="right sort-options">
                <span>Sort by: </span>
                <button id="sort_alphabetical" class="secondary primary-outline">A-Z</button>
                <button id="sort_type" class="secondary">Type</button>
                <button id="sort_date" class="secondary">Release Date</button>
            </div>
        </div>

        <div id="anime_list" class="anime-list clear" style="padding-top: 0px;">Loading...</div>
            <!-- Dynamically populated with titles loaded from a streamimg directory -->
        <div id="anime_details">

        </div>
    </section>

Each anime card is rendered dynamically and styled via Flexbox or CSS Grid depending on the active theme.

4. Smooth Interactions with Javascript

The JS layer adds the interactivity—real-time search, detail modals, and later, playback controls.

Live Search

So I broke this build down into 3 pages. The index.html, the anime-search.html, and the anime-details.html. As you would have guessed the categories and search capabilities are tied to the anime-search.html. Each theme respects the same JavaScript logic but presents it in a different skin.

// The function that needs to be fixed is the searchQuery function
    function searchQuery(data) {
        // Check if searchResults exists before trying to populate it
        if (!searchResults) {
            console.error("search_results element not found in the DOM");
            return;
        }

        console.log("Setting up search results");

        // Instead of populating all anime, let's set up a search input handler
        const searchInput = document.getElementById("search_input");
        const animeList = document.getElementById("anime_list");

        if (searchInput && animeList) {
            searchInput.addEventListener("input", function() {
                const query = this.value.toLowerCase();
                if (query.length < 2) {
                    animeList.innerHTML = "";
                    searchResults.classList.add("hidden");
                    return;
                }

                // Filter anime based on search query
                const filteredAnime = data.filter(anime => 
                    anime.title.toLowerCase().includes(query) ||
                    (anime.overview && anime.overview.toLowerCase().includes(query))
                );

                // Show search results section and hide category grid
                const catGrid = document.getElementById("category_list");
                if (catGrid) catGrid.classList.add("hidden");
                searchResults.classList.remove("hidden");

                // Display filtered results
                if (filteredAnime.length > 0) {
                    displaySearchResults(filteredAnime);
                } else {
                    animeList.innerHTML = "<p>No results found</p>";
                }
            });

            // Add event listener for search on Enter key
            searchInput.addEventListener('keydown', function(event) {
                if (event.key === 'Enter') {
                    const query = this.value.toLowerCase();
                    if (query.length < 2) return;

                    // Filter anime based on search query
                    const filteredAnime = data.filter(anime => 
                        anime.title.toLowerCase().includes(query) ||
                        (anime.overview && anime.overview.toLowerCase().includes(query)) ||
                        (Array.isArray(anime.genres) && anime.genres.some(genre => 
                            genre.toLowerCase().includes(query)
                        ))
                    );

                    // Show search results section and hide category grid
                    const catGrid = document.getElementById("category_list");
                    if (catGrid) catGrid.classList.add("hidden");
                    searchResults.classList.remove("hidden");

                    // Display filtered results
                    if (filteredAnime.length > 0) {
                        displaySearchResults(filteredAnime);
                    } else {
                        searchResults.innerHTML = "<p>No results found</p>";
                    }
                }
            });
        } else {
            console.warn("Search input element not found");
        }
    }

    function displaySearchResults(filteredData) {
        const searchResults = document.getElementById("search_results");
        if (!searchResults) return;

        const animeList = document.getElementById("anime_list");
        if (!animeList) return;

        animeList.innerHTML = " ";

        if (filteredData.length === 0) {
            animeList.innerHTML = "<p>No results found</p>";
            return;
        }

        filteredData.forEach(anime => {
            const card = document.createElement("div");
            card.classList.add("anime-card");

            if (anime.poster_path) {
                card.style.backgroundImage = `url(${anime.poster_path})`;
            } else {
                card.style.backgroundColor = "#333"; // Fallback color
            }

            const genres = Array.isArray(anime.genres) ? anime.genres.join(", ") : (anime.genres || "Unknown");
            const epCount = anime.episode_count || "Movie";
            const formattedEpCount = !isNaN(epCount) ? `${epCount} ${epCount === 1 ? 'episode' : 'episodes'}` : epCount;
            const releaseDate = anime.release_date || "Unknown";

            card.innerHTML = `
                <div class="metadata">
                    <h3>${anime.title || "Untitled"}</h3>
                    <div style="width: 100%;">
                        <span class="left category hidden">${genres}</span>
                        <span class="left category">${formattedEpCount}</span>
                        <span class="right year">${releaseDate}</span>
                    </div>
                </div>
            `;

            animeList.appendChild(card);

            // Opens the details of the chosen anime
            card.addEventListener("click", function() {
                localStorage.setItem("selectedAnime", JSON.stringify(anime));
                window.location.href = "anime-details.html";
            });
        });
    }

    // The fixed populateCategories function
    function populateCategories(data) {
        // Check if catGrid exists before trying to populate it
        const catGrid = document.getElementById("category_list");
        if (!catGrid) {
            console.error("category_list element not found in the DOM");
            return;
        }

        console.log("Populating categories");
        catGrid.innerHTML = "";

        // Create a Set to track unique genres
        const uniqueGenres = new Set();

        // Collect all unique genres
        data.forEach(anime => {
            if (Array.isArray(anime.genres)) {
                anime.genres.forEach(genre => uniqueGenres.add(genre));
            } else if (typeof anime.genres === 'string') {
                // Handle case where genres might be a comma-separated string
                const genreArray = anime.genres.split(',').map(g => g.trim());
                genreArray.forEach(genre => uniqueGenres.add(genre));
            }
        });

        console.log("Found unique genres:", Array.from(uniqueGenres));

        // Create category elements for each unique genre
        uniqueGenres.forEach(genre => {
            if (!genre) return; // Skip empty genres

            const category = document.createElement("div");
            category.classList.add("category");
            category.textContent = genre;

            catGrid.appendChild(category);

            // Shows all of the anime for that category
            category.addEventListener("click", function() {
                console.log("Category clicked: " + genre);

                // Hide the category grid
                catGrid.classList.add("hidden");

                // Get references to critical elements
                const animeList = document.getElementById("anime_list");
                const searchResultsSection = document.getElementById("search_results");

                // First show the search_results container if it exists
                if (searchResultsSection) {
                    searchResultsSection.classList.remove("hidden");
                    console.log("Showing search_results section");
                }

                // Show the anime grid
                if (animeList) {
                    // Filter anime by the selected genre
                    const filteredAnime = data.filter(anime => {
                        // Check if anime has the selected genre
                        if (Array.isArray(anime.genres)) {
                            return anime.genres.includes(genre);
                        } else if (typeof anime.genres === 'string') {
                            const genreArray = anime.genres.split(',').map(g => g.trim());
                            return genreArray.includes(genre);
                        }
                        return false;
                    });

                    console.log(`Filtered ${filteredAnime.length} anime for genre: ${genre}`);

                    // Display the filtered anime
                    displaySearchResults(filteredAnime);

                    // Update the search section title to show the category
                    const searchTitle = document.createElement("div");
                    searchTitle.classList.add("category-heading");
                    searchTitle.innerHTML = `
                        <h2><span>"${genre}"</span> anime properties</h2>
                        <div class="clear">&nbsp;</div>
                        <button class="tertiary return-to-categories"><i class="fa fa-arrow-left fa-lg"></i> All Categories</button>
                        <button class="primary-outline">Top Results</button>
                        <button class="secondary">Series</button>
                        <button class="secondary">Movies</button>
                    `;

                    // Insert the heading at the top of the search results section
                    if (searchResultsSection) {
                        // Remove any existing category heading first
                        const existingHeading = searchResultsSection.querySelector(".category-heading");
                        if (existingHeading) {
                            existingHeading.remove();
                        }

                        searchResultsSection.insertBefore(searchTitle, searchResultsSection.firstChild);

                        // Add event listener to the "Back to Categories" button
                        const backButton = searchTitle.querySelector(".return-to-categories");
                        if (backButton) {
                            backButton.addEventListener("click", function() {
                                // Remove the category heading
                                searchTitle.remove();

                                // Hide the search results section
                                searchResultsSection.classList.add("hidden");

                                // Show the category grid again
                                catGrid.classList.remove("hidden");
                            });
                        }
                    }
                } else {
                    console.error("anime_list element not found in the DOM");
                }
            });
        });
    }

5. Feeding in the Data

The UI loads metadata generated by the backend script, served from a local JSON file. Each anime entry includes:

  • Poster image
  • Title and release year
  • Runtime and genres
  • Optional episode list (for series)

Load Anime Titles as Posters

function displayAnime(data) {
        // Check if animeGrid exists before trying to populate it
        if (!animeGrid) {
            console.error("anime_list element not found in the DOM");
            return;
        }

        console.log("Displaying anime list with", data.length, "items");
        animeGrid.innerHTML = "";

        data.forEach((anime, index) => {
            try {
                const card = document.createElement("div");
                card.classList.add("anime-card");

                // Check if poster_path exists before setting it
                if (anime.poster_path) {
                    card.style.backgroundImage = `url(${anime.poster_path})`;
                } else {
                    console.warn(`No poster_path for anime at index ${index}:`, anime.title);
                    card.style.backgroundColor = "#333"; // Fallback color
                }

                const genres = Array.isArray(anime.genres) ? anime.genres.join(", ") : (anime.genres || "Unknown");
                const epCount = anime.episode_count || "Movie";
                const formattedEpCount = !isNaN(epCount) ? `${epCount} ${epCount === 1 ? 'episode' : 'episodes'}` : epCount;
                const releaseDate = anime.release_date || "Unknown";
                const title = anime.title || "Untitled";

                card.innerHTML = `
                    <div class="metadata">
                        <h3>${title}</h3>
                        <div style="width: 100%;">
                            <span class="left category hidden">${genres}</span>
                            <span class="left category">${formattedEpCount}</span>
                            <span class="right year">${releaseDate}</span>
                        </div>
                    </div>
                `;

                animeGrid.appendChild(card);

                // Opens the details of the chosen anime
                card.addEventListener("click", function() {
                    localStorage.setItem("selectedAnime", JSON.stringify(anime));
                    window.location.href = "anime-details.html";
                });

            } catch (error) {
                console.error(`Error creating card for anime at index ${index}:`, error);
            }
        });

        console.log("Finished displaying anime list");

        updateAnimeCount();
    }

What’s Next for the RAIL System?

Now that the RAIL System has a themeable, responsive, and nostalgic frontend, the next phase is all about playback. I’ll be diving into:

  • Building a custom HTML5 video player
  • Implementing “Skip Intro” and episode transition features
  • Optimizing local file streaming for fast, buffer-free performance

Josh has worked for small to enterprise organizations across various industries for close to 20 years in one design / artistic / content / media related capacity or another. He is also a collector of physical media and is always up to chatting about anything for hours on end.