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.


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.


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">×</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"> </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