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.

static/css/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.

Project structure
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.

templates/base.html

<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.

templates/base.html

<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.

app.py
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.

templates/base.html

<link rel="stylesheet" href="{{ static_version('css/dashboard.css') }}">

The generated URL will look something like this:

Generated URL
/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.

templates/base.html

<!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.