3. Add the CSS starter kit and static files
This chapter is about Flask, not CSS. To keep the focus on routes, templates, charts, forms, and Spotify data, you will use a provided stylesheet for the dashboard.
Save the stylesheet as static/css/dashboard.css. Then link it from
your base template with url_for(). Once that is in place, the pages
you build in this chapter will already have the layout, cards, buttons, forms,
navigation, and chart containers they need.
Add the stylesheet
Create a static/css/ folder in your project and add a new file called
dashboard.css.
/* dashboard.css - Music Time Machine starter kit */
:root {
--primary-color: #1DB954;
--secondary-color: #191414;
--text-color: #FFFFFF;
--text-muted: #B3B3B3;
--card-background: #282828;
--border-color: #404040;
--success-color: #1ed760;
--error-color: #e22134;
--spacing-unit: 1rem;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
Ubuntu, Cantarell, sans-serif;
background-color: var(--secondary-color);
color: var(--text-color);
line-height: 1.6;
min-height: 100vh;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: calc(var(--spacing-unit) * 2);
}
.navbar {
background-color: var(--card-background);
border-bottom: 1px solid var(--border-color);
padding: var(--spacing-unit);
}
.navbar-content {
max-width: 1200px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
}
.navbar-brand {
font-size: 1.5rem;
font-weight: bold;
color: var(--primary-color);
text-decoration: none;
}
.navbar-menu {
display: flex;
gap: calc(var(--spacing-unit) * 1.5);
list-style: none;
}
.navbar-link {
color: var(--text-muted);
text-decoration: none;
transition: color 0.2s;
}
.navbar-link:hover,
.navbar-link.active {
color: var(--text-color);
}
.card {
background-color: var(--card-background);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: calc(var(--spacing-unit) * 1.5);
margin-bottom: calc(var(--spacing-unit) * 1.5);
}
.card-header {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: var(--spacing-unit);
}
.card-content {
color: var(--text-muted);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: calc(var(--spacing-unit) * 1.5);
margin-bottom: calc(var(--spacing-unit) * 2);
}
.stat-card {
background-color: var(--card-background);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: calc(var(--spacing-unit) * 1.5);
text-align: center;
}
.stat-value {
font-size: 2.5rem;
font-weight: bold;
color: var(--primary-color);
margin-bottom: 0.5rem;
}
.stat-label {
font-size: 0.875rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.stat-subtext {
color: var(--text-muted);
font-size: 0.875rem;
margin-top: 0.35rem;
}
.page-header {
margin-bottom: calc(var(--spacing-unit) * 2);
}
.page-header h1 {
margin-bottom: 0.5rem;
}
.page-subtitle {
color: var(--text-muted);
margin: 0;
}
.chart-container {
background-color: var(--card-background);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: calc(var(--spacing-unit) * 1.5);
margin-bottom: calc(var(--spacing-unit) * 2);
}
.chart-container canvas {
width: 100% !important;
height: auto !important;
}
.btn {
display: inline-block;
padding: 0.75rem 1.5rem;
font-size: 1rem;
font-weight: 500;
text-decoration: none;
text-align: center;
border-radius: 500px;
border: none;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background-color: var(--primary-color);
color: var(--secondary-color);
}
.btn-primary:hover {
background-color: #1ed760;
transform: scale(1.05);
}
.btn-secondary {
background-color: transparent;
color: var(--text-color);
border: 1px solid var(--border-color);
}
.btn-secondary:hover {
border-color: var(--text-color);
}
.flash-messages {
margin-bottom: calc(var(--spacing-unit) * 1.5);
}
.flash {
border-radius: 6px;
padding: 0.75rem 1rem;
margin-bottom: 0.75rem;
border: 1px solid var(--border-color);
background-color: var(--card-background);
}
.flash-success {
border-color: var(--primary-color);
}
.flash-warning {
border-color: #f5c542;
}
.flash-error {
border-color: #ff6b6b;
}
.error-container {
max-width: 680px;
margin: calc(var(--spacing-unit) * 4) auto;
text-align: center;
}
.error-actions {
margin-top: calc(var(--spacing-unit) * 1.5);
}
input[type="text"],
input[type="number"],
select,
textarea {
width: 100%;
padding: 0.75rem;
background-color: var(--card-background);
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-color);
font-size: 1rem;
margin-bottom: var(--spacing-unit);
}
input:focus,
select:focus,
textarea:focus {
outline: none;
border-color: var(--primary-color);
}
@media (max-width: 768px) {
.navbar-menu {
flex-direction: column;
gap: var(--spacing-unit);
}
.stats-grid {
grid-template-columns: 1fr;
}
.container {
padding: var(--spacing-unit);
}
}
The colours are defined at the top of the file with CSS variables. If you want to change the dashboard theme later, start there. For now, leave the file as it is and move on to connecting it to Flask.
Organise the static folder
Flask serves static files from a folder named static. A small project
can get away with putting everything in one place, but a dashboard quickly has
CSS, JavaScript, and images. Keep those files separated from the start.
music-time-machine/
├── app.py
├── templates/
│ ├── base.html
│ ├── home.html
│ └── analytics.html
└── static/
├── css/
│ └── dashboard.css
├── js/
│ └── charts.js
└── images/
└── logo.png
The important path for this page is static/css/dashboard.css. Later,
when you add browser-side chart code or AJAX behaviour, those files can go in
static/js/.
Reference static files with url_for()
Do not hardcode /static/css/dashboard.css into the template. Ask Flask
to build the URL instead. That keeps the template tied to Flask's static-file
system instead of one fixed path.
<link rel="stylesheet"
href="{{ url_for('static', filename='css/dashboard.css') }}">
The filename is written relative to the static folder. That is why the
value is 'css/dashboard.css', not 'static/css/dashboard.css'.
Fix stale CSS with cache busting
Browsers cache CSS files so pages load faster. That is useful, but it can be
confusing during development. You change dashboard.css, refresh the
page, and the browser may still show the old version.
A common fix is to add a version value to the CSS URL. When the version changes, the browser treats the stylesheet as a new file and downloads it again.
<link rel="stylesheet"
href="{{ url_for('static', filename='css/dashboard.css') }}?v=1">
Manual version numbers work, but you have to remember to change them. A better small-project solution is to use the file's modification time as the version. When the CSS file changes, the version changes automatically.
import os
from flask import Flask, url_for
app = Flask(__name__)
@app.context_processor
def inject_static_version():
def static_version(filename):
path = os.path.join(app.root_path, "static", filename)
try:
version = int(os.path.getmtime(path))
except OSError:
version = 0
return f"{url_for('static', filename=filename)}?v={version}"
return {"static_version": static_version}
Now use static_version() in the template instead of calling
url_for() directly for files you want to cache-bust.
<link rel="stylesheet" href="{{ static_version('css/dashboard.css') }}">
The generated URL will look something like this:
/static/css/dashboard.css?v=1733315123
The stylesheet file has not moved. The version value simply gives the browser a new URL whenever the file changes.
Create the shared base layout
Now create templates/base.html. Every page in the dashboard extends
this file, so it is the right place for the stylesheet link, the navigation,
flashed messages, and the shared JavaScript libraries.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{% block title %}Music Time Machine{% endblock %}</title>
<link rel="stylesheet" href="{{ static_version('css/dashboard.css') }}">
{% block head %}{% endblock %}
</head>
<body>
<nav class="navbar">
<div class="navbar-content">
<a class="navbar-brand" href="{{ url_for('home') }}">Music Time Machine</a>
<ul class="navbar-menu">
<li><a class="navbar-link" href="{{ url_for('home') }}">Home</a></li>
<li><a class="navbar-link" href="{{ url_for('analytics') }}">Analytics</a></li>
<li><a class="navbar-link" href="{{ url_for('playlists') }}">Playlists</a></li>
<li><a class="navbar-link" href="{{ url_for('settings') }}">Settings</a></li>
<li><a class="navbar-link" href="{{ url_for('logout') }}">Log out</a></li>
</ul>
</div>
</nav>
<main class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-messages">
{% for category, message in messages %}
<div class="flash flash-{{ category }}">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>
Chart.js is loaded in the base layout because both the home dashboard and the
Analytics page draw charts. The page-specific scripts block still lets
each page add its own JavaScript after the shared library is available.
Static files are public
Anything inside static/ can be requested by a browser. That is exactly
what you want for CSS, JavaScript, icons, and images. It is not what you want
for secrets.
- Put stylesheets in
static/css/ - Put browser JavaScript in
static/js/ - Put images and icons in
static/images/ - Use
url_for('static', filename='...')or a helper built on top of it - Do not put API keys, tokens, database files, or config files in
static/
At this point the dashboard has a stylesheet, a place for future browser-side files, a safe way to avoid stale CSS while you build, and the shared base layout every dashboard page will inherit from.