How to Customize Layouts in Plotly

Plotly creates decent-looking charts out of the box, but default layouts rarely meet professional standards. Whether you're building dashboards, preparing presentations, or publishing reports, you...

Key Insights

  • Plotly’s update_layout() method provides complete control over every visual aspect of your charts, from axes and titles to margins and hover behavior
  • Professional visualizations require consistent styling across background colors, fonts, grid lines, and spacing—creating reusable templates saves time and ensures brand consistency
  • Strategic use of annotations, shapes, and custom hover templates transforms basic charts into interactive, publication-ready visualizations that communicate insights effectively

Introduction to Plotly Layout Customization

Plotly creates decent-looking charts out of the box, but default layouts rarely meet professional standards. Whether you’re building dashboards, preparing presentations, or publishing reports, you need precise control over every visual element. That’s where layout customization comes in.

The update_layout() method is your primary tool for modifying everything outside the data traces themselves—titles, axes, backgrounds, margins, legends, and more. Think of it as the styling layer that transforms raw data visualizations into polished, communicative graphics.

Here’s the difference customization makes:

import plotly.graph_objects as go
import numpy as np

# Sample data
x = np.linspace(0, 10, 100)
y1 = np.sin(x)
y2 = np.cos(x)

# Default layout
fig_default = go.Figure()
fig_default.add_trace(go.Scatter(x=x, y=y1, name='sin(x)'))
fig_default.add_trace(go.Scatter(x=x, y=y2, name='cos(x)'))

# Customized layout
fig_custom = go.Figure()
fig_custom.add_trace(go.Scatter(x=x, y=y1, name='sin(x)', line=dict(width=3)))
fig_custom.add_trace(go.Scatter(x=x, y=y2, name='cos(x)', line=dict(width=3)))
fig_custom.update_layout(
    title=dict(text='Trigonometric Functions', font=dict(size=24, family='Arial Black')),
    xaxis_title='Radians',
    yaxis_title='Amplitude',
    plot_bgcolor='#f8f9fa',
    paper_bgcolor='white',
    font=dict(size=14),
    hovermode='x unified',
    margin=dict(l=60, r=40, t=80, b=60)
)

fig_custom.show()

The customized version immediately looks more professional and intentional. Let’s break down how to achieve this level of control.

Configuring Axes and Titles

Axes are the foundation of your chart’s readability. Poor axis configuration—unclear labels, cramped ranges, or illegible tick marks—undermines even the best data.

Start with clear, descriptive titles and appropriate ranges:

fig = go.Figure(go.Scatter(x=[1, 2, 3, 4, 5], y=[10, 25, 15, 35, 30]))

fig.update_layout(
    title=dict(
        text='Monthly Revenue Growth<br><sub>Q1 2024 Performance</sub>',
        font=dict(size=22, color='#1f77b4', family='Arial'),
        x=0.5,  # Center the title
        xanchor='center'
    ),
    xaxis=dict(
        title='Month',
        titlefont=dict(size=16, color='#2c3e50'),
        tickmode='array',
        tickvals=[1, 2, 3, 4, 5],
        ticktext=['Jan', 'Feb', 'Mar', 'Apr', 'May'],
        tickangle=-45,
        showgrid=True,
        gridcolor='#ecf0f1',
        range=[0.5, 5.5]  # Add padding
    ),
    yaxis=dict(
        title='Revenue ($)',
        titlefont=dict(size=16, color='#2c3e50'),
        tickprefix='$',
        ticksuffix='K',
        showgrid=True,
        gridcolor='#ecf0f1',
        zeroline=True,
        zerolinecolor='#95a5a6',
        zerolinewidth=2
    )
)

Notice the HTML formatting in the title—Plotly supports basic HTML tags like <br>, <sub>, <sup>, <b>, and <i>. This lets you create multi-line titles with different styling levels.

For axis ranges, always consider your data distribution. Setting explicit ranges with range=[min, max] prevents Plotly from auto-scaling inappropriately. Add 5-10% padding beyond your data extremes for visual breathing room.

Styling the Plot Canvas

The canvas includes everything you see: the plot area (where data appears) and the paper (the entire figure). Professional visualizations use subtle backgrounds that don’t compete with data.

fig = go.Figure()
fig.add_trace(go.Bar(x=['A', 'B', 'C', 'D'], y=[12, 19, 8, 15]))

fig.update_layout(
    # Figure dimensions
    width=800,
    height=500,
    
    # Margins (left, right, top, bottom)
    margin=dict(l=80, r=40, t=100, b=60),
    
    # Background colors
    paper_bgcolor='#ffffff',  # Outer background
    plot_bgcolor='#f8f9fa',   # Plot area background
    
    # Grid customization
    xaxis=dict(
        showgrid=False,  # Typically hide vertical grids for categorical data
        showline=True,
        linewidth=2,
        linecolor='#2c3e50'
    ),
    yaxis=dict(
        showgrid=True,
        gridwidth=1,
        gridcolor='#dfe6e9',
        showline=True,
        linewidth=2,
        linecolor='#2c3e50'
    )
)

Creating a reusable template ensures consistency across multiple visualizations:

# Define your custom template
custom_template = dict(
    layout=dict(
        font=dict(family='Segoe UI, Arial, sans-serif', size=13, color='#2c3e50'),
        title=dict(font=dict(size=20), x=0.5, xanchor='center'),
        paper_bgcolor='white',
        plot_bgcolor='#fafafa',
        hovermode='closest',
        margin=dict(l=60, r=40, t=80, b=60),
        xaxis=dict(showgrid=False, showline=True, linecolor='#34495e'),
        yaxis=dict(showgrid=True, gridcolor='#ecf0f1', showline=True, linecolor='#34495e')
    )
)

# Apply to any figure
fig = go.Figure(data=your_traces)
fig.update_layout(template=custom_template)

Legends and Annotations

Legends need strategic placement to avoid obscuring data. Annotations add context that transforms charts from descriptive to explanatory.

fig = go.Figure()
fig.add_trace(go.Scatter(x=[1, 2, 3, 4], y=[10, 15, 13, 17], name='Product A'))
fig.add_trace(go.Scatter(x=[1, 2, 3, 4], y=[8, 12, 14, 16], name='Product B'))

fig.update_layout(
    # Legend configuration
    legend=dict(
        orientation='h',  # Horizontal legend
        yanchor='bottom',
        y=1.02,
        xanchor='right',
        x=1,
        bgcolor='rgba(255, 255, 255, 0.8)',
        bordercolor='#bdc3c7',
        borderwidth=1,
        font=dict(size=12)
    ),
    
    # Add annotations
    annotations=[
        dict(
            x=2,
            y=15,
            text='Peak Season',
            showarrow=True,
            arrowhead=2,
            arrowsize=1,
            arrowwidth=2,
            arrowcolor='#e74c3c',
            ax=-40,
            ay=-40,
            font=dict(size=12, color='#e74c3c'),
            bgcolor='rgba(255, 255, 255, 0.9)',
            bordercolor='#e74c3c',
            borderwidth=1,
            borderpad=4
        )
    ]
)

Add reference regions using shapes:

fig.update_layout(
    shapes=[
        # Horizontal reference line
        dict(
            type='line',
            x0=1, x1=4,
            y0=14, y1=14,
            line=dict(color='#27ae60', width=2, dash='dash')
        ),
        # Shaded region
        dict(
            type='rect',
            x0=2.5, x1=3.5,
            y0=0, y1=20,
            fillcolor='rgba(231, 76, 60, 0.1)',
            line=dict(width=0)
        )
    ]
)

Advanced Layout Features

Custom hover templates improve data exploration significantly:

fig = go.Figure(go.Scatter(
    x=[1, 2, 3, 4],
    y=[100, 250, 180, 320],
    mode='markers+lines',
    hovertemplate='<b>Quarter %{x}</b><br>' +
                  'Revenue: $%{y:,.0f}<br>' +
                  'Growth: %{customdata}%<extra></extra>',
    customdata=[0, 150, -28, 78]
))

fig.update_layout(
    hoverlabel=dict(
        bgcolor='white',
        font_size=13,
        font_family='Arial',
        bordercolor='#3498db'
    )
)

The <extra></extra> tag removes the trace name from hover labels—useful for cleaner single-trace charts.

For subplots, control spacing precisely:

from plotly.subplots import make_subplots

fig = make_subplots(
    rows=2, cols=2,
    subplot_titles=('Q1', 'Q2', 'Q3', 'Q4'),
    horizontal_spacing=0.12,
    vertical_spacing=0.15
)

# Add traces to subplots...

fig.update_layout(
    height=600,
    showlegend=False,
    title_text='Quarterly Performance Overview',
    title_x=0.5
)

Configure the modebar (toolbar) for embedded dashboards:

fig.update_layout(
    modebar=dict(
        orientation='v',
        bgcolor='rgba(255, 255, 255, 0.7)',
        color='#7f8c8d',
        activecolor='#2c3e50'
    )
)

# Or remove specific buttons
config = {'modeBarButtonsToRemove': ['pan2d', 'lasso2d']}
fig.show(config=config)

Best Practices and Common Patterns

Consistency is non-negotiable. Define your styling once and reuse it. Create a layouts.py module with pre-configured templates for different chart types.

Accessibility matters. Use colorblind-friendly palettes, ensure sufficient contrast (WCAG AA requires 4.5:1 for text), and set minimum font sizes of 12px. Test your charts in grayscale.

Performance optimization: For large datasets, simplify layouts. Disable hover labels if you have thousands of points, reduce grid density, and avoid complex annotations.

Here’s a complete real-world example combining multiple techniques:

import plotly.graph_objects as go
import pandas as pd

# Sample data
df = pd.DataFrame({
    'month': ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'],
    'actual': [45, 52, 48, 61, 58, 67],
    'target': [50, 50, 55, 55, 60, 60]
})

fig = go.Figure()

# Actual performance
fig.add_trace(go.Scatter(
    x=df['month'], y=df['actual'],
    name='Actual',
    mode='lines+markers',
    line=dict(color='#3498db', width=3),
    marker=dict(size=10),
    hovertemplate='%{x}<br>Actual: %{y}<extra></extra>'
))

# Target line
fig.add_trace(go.Scatter(
    x=df['month'], y=df['target'],
    name='Target',
    mode='lines',
    line=dict(color='#95a5a6', width=2, dash='dash'),
    hovertemplate='%{x}<br>Target: %{y}<extra></extra>'
))

fig.update_layout(
    title=dict(
        text='Sales Performance Dashboard<br><sub>H1 2024 Results</sub>',
        font=dict(size=24, family='Arial Black', color='#2c3e50'),
        x=0.5,
        xanchor='center'
    ),
    xaxis=dict(
        title='Month',
        showgrid=False,
        showline=True,
        linewidth=2,
        linecolor='#34495e',
        mirror=True
    ),
    yaxis=dict(
        title='Sales (Units)',
        showgrid=True,
        gridwidth=1,
        gridcolor='#ecf0f1',
        showline=True,
        linewidth=2,
        linecolor='#34495e',
        mirror=True,
        range=[0, 75]
    ),
    plot_bgcolor='#fafafa',
    paper_bgcolor='white',
    font=dict(family='Arial, sans-serif', size=13, color='#2c3e50'),
    hovermode='x unified',
    hoverlabel=dict(bgcolor='white', font_size=12),
    legend=dict(
        orientation='h',
        yanchor='bottom',
        y=1.02,
        xanchor='right',
        x=1,
        bgcolor='rgba(255, 255, 255, 0.9)',
        bordercolor='#bdc3c7',
        borderwidth=1
    ),
    margin=dict(l=70, r=40, t=120, b=60),
    height=500,
    annotations=[
        dict(
            x='Apr', y=61,
            text='Target<br>Exceeded',
            showarrow=True,
            arrowhead=2,
            arrowcolor='#27ae60',
            ax=30, ay=-40,
            font=dict(color='#27ae60', size=11),
            bgcolor='rgba(255, 255, 255, 0.9)',
            bordercolor='#27ae60',
            borderwidth=1
        )
    ],
    shapes=[
        dict(
            type='rect',
            x0='Apr', x1='Jun',
            y0=0, y1=75,
            fillcolor='rgba(39, 174, 96, 0.05)',
            line=dict(width=0)
        )
    ]
)

fig.show()

This example demonstrates professional-grade customization: clear hierarchy through typography, strategic use of color, contextual annotations, and a clean, uncluttered layout that prioritizes data visibility. Apply these patterns consistently, and your visualizations will communicate insights effectively every time.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.