Repository: nilearn/nilearn

Issue:
Adding Plotly (Python) figures for surface plots
- Matplotlib is not great for 3d (values for faces not vertices, problems with aspect ratios, sometimes slow, plots don't look as good as those produced by other libraries...)
- the `view_*` functions dump plots in an html page so there no way to export to static image formats such as png (except manually clicking on a button after opening the page in a browser)

to easily get nice surface images we can add an **optional** dependency on `plotly` and `kaleido` (if there are suggestions for other libraries **easily installed** with `pip` we could also consider them)

One way to insert these new plots would be to add a keyword argument to the existing `plot_surf*` functions, eg `plotting_engine="matplotlib"` (default), or `"plotly"`.

as a starting point, here is a draft of how a plotly figure for a surface image can be created and saved:

```python
"""requirements: nilearn, plotly, kaleido"""
import plotly.graph_objects as go

from nilearn import datasets, surface
from nilearn.plotting.cm import cold_hot
from nilearn.plotting.js_plotting_utils import colorscale
from nilearn.plotting.html_surface import _get_vertexcolor

AXIS_CONFIG = {
    "showgrid": False,
    "showline": False,
    "ticks": "",
    "title": "",
    "showticklabels": False,
    "zeroline": False,
    "showspikes": False,
    "spikesides": False,
    "showbackground": False,
}

CAMERAS = {
    "left": {
        "eye": {"x": -1.7, "y": 0, "z": 0},
        "up": {"x": 0, "y": 0, "z": 1},
        "center": {"x": 0, "y": 0, "z": 0},
    },
    "right": {
        "eye": {"x": 1.7, "y": 0, "z": 0},
        "up": {"x": 0, "y": 0, "z": 1},
        "center": {"x": 0, "y": 0, "z": 0},
    },
    "top": {
        "eye": {"x": 0, "y": 0, "z": 1.7},
        "up": {"x": 0, "y": 1, "z": 0},
        "center": {"x": 0, "y": 0, "z": 0},
    },
    "bottom": {
        "eye": {"x": 0, "y": 0, "z": -1.7},
        "up": {"x": 0, "y": 1, "z": 0},
        "center": {"x": 0, "y": 0, "z": 0},
    },
    "front": {
        "eye": {"x": 0, "y": 1.7, "z": 0},
        "up": {"x": 0, "y": 0, "z": 1},
        "center": {"x": 0, "y": 0, "z": 0},
    },
    "back": {
        "eye": {"x": 0, "y": -1.7, "z": 0},
        "up": {"x": 0, "y": 0, "z": 1},
        "center": {"x": 0, "y": 0, "z": 0},
    },
}
LAYOUT = {
    "scene": {f"{dim}axis": AXIS_CONFIG for dim in ("x", "y", "z")},
    "paper_bgcolor": "#fff",
    "hovermode": False,
    "margin": {"l": 0, "r": 0, "b": 0, "t": 0, "pad": 0},
}


def plot_surf(mesh, data, view="right", threshold="85%", output_file=None):
    coords, triangles = surface.load_surf_mesh(mesh)
    x, y, z = coords.T
    i, j, k = triangles.T
    colors = colorscale(cold_hot, data, threshold)
    vertexcolor = _get_vertexcolor(
        data,
        colors["cmap"],
        colors["norm"],
        colors["abs_threshold"],
        surface.load_surf_data(fsaverage["sulc_right"]),
    )
    mesh_3d = go.Mesh3d(x=x, y=y, z=z, i=i, j=j, k=k, vertexcolor=vertexcolor)
    fig = go.Figure(data=[mesh_3d])
    fig.update_layout(scene_camera=CAMERAS[view], **LAYOUT)
    if output_file is not None:
        fig.write_image(output_file)
    return fig


if __name__ == "__main__":
    img = datasets.fetch_neurovault_motor_task()["images"][0]
    fsaverage = datasets.fetch_surf_fsaverage()
    mesh = fsaverage["pial_right"]
    data = surface.vol_to_surf(img, fsaverage["pial_right"])
    fig = plot_surf(mesh, data, output_file="/tmp/surface_plot.png")
    fig.show()
```


![surface_plot](https://user-images.githubusercontent.com/9196501/117147568-5e56cd00-ad83-11eb-906b-19f4497d11cf.png)



Discussion:
Fantastic stuff @jeromedockes, +1 on all points you outlined. For example, for a while, I'm trying to integrate `nilearn surface plots` to [mindboggle](https://github.com/nipy/mindboggle) (e.g. within this [issue](https://github.com/nipy/mindboggle/issues/177)) and are currently working on other packages where this is needed as well. I would be happy to help where possible!   
>  I would be happy to help where possible!   

Fantastic! shall I assign you to this issue then?

I guess it would entail

- maybe having a quick look at other options to justify the choice of
  plotly. the main points are being stable and (reliably) easy to
  install. also nilearn already has utilities to prepare the data needed
  by plotly, because plotly (the javascript library) is used in the
  `view_surf` & co. functions

- adding a ci job with plotly python & kaleido installed

- adding an implementation for plot_surf, plot_surf_stat_map,
  plot_surf_roi using plotly (or just plot_surf and the others can be
  done in separate prs) -- which I don't think will require writing a
  lot of new code as you can see in the issue description


Resolving patch:
diff --git a/nilearn/plotting/surf_plotting.py b/nilearn/plotting/surf_plotting.py
--- a/nilearn/plotting/surf_plotting.py
+++ b/nilearn/plotting/surf_plotting.py
@@ -1,6 +1,5 @@
 """
 Functions for surface visualization.
-Only matplotlib is required.
 """
 import itertools
 
@@ -13,6 +12,7 @@
 from matplotlib.colors import Normalize, LinearSegmentedColormap
 from mpl_toolkits.mplot3d import Axes3D  # noqa
 from nilearn import image
+from nilearn.plotting.cm import cold_hot
 from nilearn.plotting.img_plotting import (_get_colorbar_and_data_ranges,
                                            _crop_colorbar)
 from nilearn.surface import (load_surf_data,
@@ -20,6 +20,8 @@
                              vol_to_surf)
 from nilearn.surface.surface import _check_mesh
 from nilearn._utils import check_niimg_3d, fill_doc
+from nilearn.plotting.js_plotting_utils import colorscale
+from nilearn.plotting.html_surface import _get_vertexcolor
 
 from matplotlib.colors import to_rgba
 from matplotlib.patches import Patch
@@ -29,13 +31,510 @@
 VALID_HEMISPHERES = "left", "right"
 
 
+AXIS_CONFIG = {
+    "showgrid": False,
+    "showline": False,
+    "ticks": "",
+    "title": "",
+    "showticklabels": False,
+    "zeroline": False,
+    "showspikes": False,
+    "spikesides": False,
+    "showbackground": False,
+}
+
+
+MATPLOTLIB_VIEWS = {"right": {"lateral": (0, 0),
+                              "medial": (0, 180),
+                              "dorsal": (90, 0),
+                              "ventral": (270, 0),
+                              "anterior": (0, 90),
+                              "posterior": (0, 270)
+                              },
+                    "left": {"medial": (0, 0),
+                             "lateral": (0, 180),
+                             "dorsal": (90, 0),
+                             "ventral": (270, 0),
+                             "anterior": (0, 90),
+                             "posterior": (0, 270)
+                             }
+                    }
+
+
+CAMERAS = {
+    "left": {
+        "eye": {"x": -1.5, "y": 0, "z": 0},
+        "up": {"x": 0, "y": 0, "z": 1},
+        "center": {"x": 0, "y": 0, "z": 0},
+    },
+    "right": {
+        "eye": {"x": 1.5, "y": 0, "z": 0},
+        "up": {"x": 0, "y": 0, "z": 1},
+        "center": {"x": 0, "y": 0, "z": 0},
+    },
+    "dorsal": {
+        "eye": {"x": 0, "y": 0, "z": 1.5},
+        "up": {"x": 0, "y": 1, "z": 0},
+        "center": {"x": 0, "y": 0, "z": 0},
+    },
+    "ventral": {
+        "eye": {"x": 0, "y": 0, "z": -1.5},
+        "up": {"x": 0, "y": 1, "z": 0},
+        "center": {"x": 0, "y": 0, "z": 0},
+    },
+    "anterior": {
+        "eye": {"x": 0, "y": 1.5, "z": 0},
+        "up": {"x": 0, "y": 0, "z": 1},
+        "center": {"x": 0, "y": 0, "z": 0},
+    },
+    "posterior": {
+        "eye": {"x": 0, "y": -1.5, "z": 0},
+        "up": {"x": 0, "y": 0, "z": 1},
+        "center": {"x": 0, "y": 0, "z": 0},
+    },
+}
+
+
+LAYOUT = {
+    "scene": {f"{dim}axis": AXIS_CONFIG for dim in ("x", "y", "z")},
+    "paper_bgcolor": "#fff",
+    "hovermode": False,
+    "margin": {"l": 0, "r": 0, "b": 0, "t": 0, "pad": 0},
+}
+
+
+def _set_view_plot_surf_plotly(hemi, view):
+    """Helper function for plot_surf with plotly engine.
+
+    This function checks the selected hemisphere and view, and
+    returns the cameras view.
+    """
+    if hemi not in VALID_HEMISPHERES:
+        raise ValueError(f"hemi must be one of {VALID_HEMISPHERES}")
+    if view == 'lateral':
+        view = hemi
+    elif view == 'medial':
+        view = (VALID_HEMISPHERES[0]
+                if hemi == VALID_HEMISPHERES[1]
+                else VALID_HEMISPHERES[1])
+    if view not in CAMERAS:
+        raise ValueError(f"view must be one of {VALID_VIEWS}")
+    return view
+
+
+def _configure_title_plotly(title, font_size, color="black"):
+    """Helper function for plot_surf with plotly engine.
+
+    This function configures the title if provided.
+    """
+    if title is None:
+        return dict()
+    return {"text": title,
+            "font": {"size": font_size,
+                     "color": color,
+                     },
+            "y": 0.96,
+            "x": 0.5,
+            "xanchor": "center",
+            "yanchor": "top"}
+
+
+def _get_cbar_plotly(colorscale, vmin, vmax, cbar_tick_format,
+                     fontsize=25, color="black", height=0.5):
+    """Helper function for _plot_surf_plotly.
+
+    This function configures the colorbar and creates a small
+    invisible plot that uses the appropriate cmap to trigger
+    the generation of the colorbar. This dummy plot has then to
+    be added to the figure.
+    """
+    dummy = {
+        "opacity": 0,
+        "colorbar": {
+            "tickfont": {"size": fontsize, "color": color},
+            "tickformat": cbar_tick_format,
+            "len": height,
+        },
+        "type": "mesh3d",
+        "colorscale": colorscale,
+        "x": [1, 0, 0],
+        "y": [0, 1, 0],
+        "z": [0, 0, 1],
+        "i": [0],
+        "j": [1],
+        "k": [2],
+        "intensity": [0.0],
+        "cmin": vmin,
+        "cmax": vmax,
+    }
+    return dummy
+
+
+def _plot_surf_plotly(coords, faces, surf_map=None, bg_map=None,
+                      hemi='left', view='lateral', cmap=None,
+                      symmetric_cmap=True, colorbar=False,
+                      threshold=None, vmin=None, vmax=None,
+                      cbar_vmin=None, cbar_vmax=None,
+                      cbar_tick_format=".1f", title=None,
+                      title_font_size=18, output_file=None):
+    """Helper function for plot_surf.
+
+    .. versionadded:: 0.8.2
+
+    This function handles surface plotting when the selected
+    engine is plotly.
+
+    .. note::
+        This function assumes that plotly and kaleido are
+        installed.
+
+    .. warning::
+        This function is new and experimental. Please report
+        bugs that you may encounter.
+
+    """
+    try:
+        import plotly.graph_objects as go
+    except ImportError:
+        msg = "Using engine='plotly' requires that ``plotly`` is installed."
+        raise ImportError(msg)  # noqa
+    x, y, z = coords.T
+    i, j, k = faces.T
+    if cmap is None:
+        cmap = cold_hot
+    bg_data = None
+    if bg_map is not None:
+        bg_data = load_surf_data(bg_map)
+        if bg_data.shape[0] != coords.shape[0]:
+            raise ValueError('The bg_map does not have the same number '
+                             'of vertices as the mesh.')
+    if surf_map is not None:
+        _check_surf_map(surf_map, coords.shape[0])
+        colors = colorscale(
+            cmap, surf_map, threshold, vmax=vmax, vmin=vmin,
+            symmetric_cmap=symmetric_cmap
+        )
+        vertexcolor = _get_vertexcolor(
+            surf_map, colors["cmap"], colors["norm"],
+            colors["abs_threshold"], bg_data
+        )
+    else:
+        if bg_data is None:
+            bg_data = np.zeros(coords.shape[0])
+        colors = colorscale('Greys', bg_data, symmetric_cmap=False)
+        vertexcolor = _get_vertexcolor(
+            bg_data, colors["cmap"], colors["norm"],
+            colors["abs_threshold"]
+        )
+    mesh_3d = go.Mesh3d(x=x, y=y, z=z, i=i, j=j, k=k, vertexcolor=vertexcolor)
+    fig_data = [mesh_3d]
+    if colorbar:
+        dummy = _get_cbar_plotly(
+            colors["colors"], float(colors["vmin"]), float(colors["vmax"]),
+            cbar_tick_format
+        )
+        fig_data.append(dummy)
+    cameras_view = _set_view_plot_surf_plotly(hemi, view)
+    fig = go.Figure(data=fig_data)
+    fig.update_layout(scene_camera=CAMERAS[cameras_view],
+                      title=_configure_title_plotly(title, title_font_size),
+                      **LAYOUT)
+    if output_file is not None:
+        try:
+            import kaleido  # noqa: F401
+        except ImportError:
+            msg = ("Saving figures to file with engine='plotly' requires "
+                   "that ``kaleido`` is installed.")
+            raise ImportError(msg)  # noqa
+        fig.write_image(output_file)
+    return fig
+
+
+def _set_view_plot_surf_matplotlib(hemi, view):
+    """Helper function for plot_surf with matplotlib engine.
+
+    This function checks the selected hemisphere and view, and
+    returns elev and azim.
+    """
+    if hemi not in VALID_HEMISPHERES:
+        raise ValueError(f"hemi must be one of {VALID_HEMISPHERES}")
+    if view not in MATPLOTLIB_VIEWS[hemi]:
+        raise ValueError(f"view must be one of {VALID_VIEWS}")
+    return MATPLOTLIB_VIEWS[hemi][view]
+
+
+def _check_surf_map(surf_map, n_vertices):
+    """Helper function for plot_surf.
+
+    This function checks the dimensions of provided surf_map.
+    """
+    surf_map_data = load_surf_data(surf_map)
+    if surf_map_data.ndim != 1:
+        raise ValueError("'surf_map' can only have one dimension "
+                         f"but has '{surf_map_data.ndim}' dimensions")
+    if surf_map_data.shape[0] != n_vertices:
+        raise ValueError('The surf_map does not have the same number '
+                         'of vertices as the mesh.')
+    return surf_map_data
+
+
+def _compute_surf_map_faces_matplotlib(surf_map, faces, avg_method,
+                                       n_vertices, face_colors_size):
+    """Helper function for plot_surf.
+
+    This function computes the surf map faces using the
+    provided averaging method.
+
+    .. note::
+        This method is called exclusively when using matplotlib,
+        since it only supports plotting face-colour maps and not
+        vertex-colour maps.
+
+    """
+    surf_map_data = _check_surf_map(surf_map, n_vertices)
+
+    # create face values from vertex values by selected avg methods
+    error_message = ("avg_method should be either "
+                     "['mean', 'median', 'max', 'min'] "
+                     "or a custom function")
+    if isinstance(avg_method, str):
+        try:
+            avg_method = getattr(np, avg_method)
+        except AttributeError:
+            raise ValueError(error_message)
+        surf_map_faces = avg_method(surf_map_data[faces], axis=1)
+    elif callable(avg_method):
+        surf_map_faces = np.apply_along_axis(
+            avg_method, 1, surf_map_data[faces]
+        )
+
+        # check that surf_map_faces has the same length as face_colors
+        if surf_map_faces.shape != (face_colors_size,):
+            raise ValueError(
+                "Array computed with the custom function "
+                "from avg_method does not have the correct shape: "
+                f"{surf_map_faces[0]} != {face_colors_size}")
+
+        # check that dtype is either int or float
+        if not (
+            "int" in str(surf_map_faces.dtype)
+            or "float" in str(surf_map_faces.dtype)
+        ):
+            raise ValueError(
+                'Array computed with the custom function '
+                'from avg_method should be an array of numbers '
+                '(int or float)'
+            )
+    else:
+        raise ValueError(error_message)
+    return surf_map_faces
+
+
+def _get_ticks_matplotlib(vmin, vmax, cbar_tick_format):
+    """Helper function for plot_surf with matplotlib engine.
+
+    This function computes the tick values for the colorbar.
+    """
+    # Default number of ticks is 5...
+    n_ticks = 5
+    # ...unless we are dealing with integers with a small range
+    # in this case, we reduce the number of ticks
+    if cbar_tick_format == "%i" and vmax - vmin < n_ticks:
+        ticks = np.arange(vmin, vmax + 1)
+        n_ticks = len(ticks)
+    else:
+        ticks = np.linspace(vmin, vmax, n_ticks)
+    return ticks
+
+
+def _get_cmap_matplotlib(cmap, vmin, vmax, threshold=None):
+    """Helper function for plot_surf with matplotlib engine.
+
+    This function returns the colormap.
+    """
+    our_cmap = get_cmap(cmap)
+    norm = Normalize(vmin=vmin, vmax=vmax)
+    cmaplist = [our_cmap(i) for i in range(our_cmap.N)]
+    if threshold is not None:
+        # set colors to grey for absolute values < threshold
+        istart = int(norm(-threshold, clip=True) * (our_cmap.N - 1))
+        istop = int(norm(threshold, clip=True) * (our_cmap.N - 1))
+        for i in range(istart, istop):
+            cmaplist[i] = (0.5, 0.5, 0.5, 1.)
+    our_cmap = LinearSegmentedColormap.from_list(
+        'Custom cmap', cmaplist, our_cmap.N)
+    return our_cmap, norm
+
+
+def _compute_facecolors_matplotlib(bg_map, faces, n_vertices,
+                                   darkness, alpha):
+    """Helper function for plot_surf with matplotlib engine.
+
+    This function computes the facecolors.
+    """
+    # set alpha if in auto mode
+    if alpha == 'auto':
+        alpha = .5 if bg_map is None else 1
+    face_colors = np.ones((faces.shape[0], 4))
+    if bg_map is None:
+        bg_data = np.ones(n_vertices) * 0.5
+    else:
+        bg_data = load_surf_data(bg_map)
+        if bg_data.shape[0] != n_vertices:
+            raise ValueError('The bg_map does not have the same number '
+                             'of vertices as the mesh.')
+    bg_faces = np.mean(bg_data[faces], axis=1)
+    if bg_faces.min() != bg_faces.max():
+        bg_faces = bg_faces - bg_faces.min()
+        bg_faces = bg_faces / bg_faces.max()
+    # control background darkness
+    bg_faces *= darkness
+    face_colors = plt.cm.gray_r(bg_faces)
+    # modify alpha values of background
+    face_colors[:, 3] = alpha * face_colors[:, 3]
+    return face_colors
+
+
+def _threshold_and_rescale(data, threshold, vmin, vmax):
+    """Helper function for plot_surf.
+
+    This function thresholds and rescales the provided data.
+    """
+    data_copy, vmin, vmax = _rescale(data, vmin, vmax)
+    return data_copy, _threshold(data, threshold), vmin, vmax
+
+
+def _threshold(data, threshold):
+    """Thresholds the data."""
+    # If no thresholding and nans, filter them out
+    return (np.logical_not(np.isnan(data)) if threshold is None
+            else np.abs(data) >= threshold)
+
+
+def _rescale(data, vmin=None, vmax=None):
+    """Rescales the data."""
+    data_copy = np.copy(data)
+    # if no vmin/vmax are passed figure them out from data
+    vmin, vmax = _get_bounds(data_copy, vmin, vmax)
+    data_copy -= vmin
+    data_copy /= (vmax - vmin)
+    return data_copy, vmin, vmax
+
+
+def _get_bounds(data, vmin=None, vmax=None):
+    """Helper function returning the data bounds."""
+    vmin = np.nanmin(data) if vmin is None else vmin
+    vmax = np.nanmax(data) if vmax is None else vmax
+    return vmin, vmax
+
+
+def _plot_surf_matplotlib(coords, faces, surf_map=None, bg_map=None,
+                          hemi='left', view='lateral', cmap=None,
+                          colorbar=False, avg_method='mean', threshold=None,
+                          alpha='auto', bg_on_data=False, darkness=1,
+                          vmin=None, vmax=None, cbar_vmin=None,
+                          cbar_vmax=None, cbar_tick_format='%.2g',
+                          title=None, title_font_size=18, output_file=None,
+                          axes=None, figure=None, **kwargs):
+    """Helper function for plot_surf.
+
+    This function handles surface plotting when the selected
+    engine is matplotlib.
+    """
+    _default_figsize = [4, 4]
+    limits = [coords.min(), coords.max()]
+
+    # set view
+    elev, azim = _set_view_plot_surf_matplotlib(hemi, view)
+
+    # if no cmap is given, set to matplotlib default
+    if cmap is None:
+        cmap = plt.cm.get_cmap(plt.rcParamsDefault['image.cmap'])
+    # if cmap is given as string, translate to matplotlib cmap
+    elif isinstance(cmap, str):
+        cmap = plt.cm.get_cmap(cmap)
+
+    figsize = _default_figsize
+    # Leave space for colorbar
+    if colorbar:
+        figsize[0] += .7
+    # initiate figure and 3d axes
+    if axes is None:
+        if figure is None:
+            figure = plt.figure(figsize=figsize)
+        axes = figure.add_axes((0, 0, 1, 1), projection="3d")
+    else:
+        if figure is None:
+            figure = axes.get_figure()
+    axes.set_xlim(*limits)
+    axes.set_ylim(*limits)
+    axes.view_init(elev=elev, azim=azim)
+    axes.set_axis_off()
+
+    # plot mesh without data
+    p3dcollec = axes.plot_trisurf(coords[:, 0], coords[:, 1], coords[:, 2],
+                                  triangles=faces, linewidth=0.,
+                                  antialiased=False,
+                                  color='white')
+
+    # reduce viewing distance to remove space around mesh
+    axes.dist = 8
+
+    face_colors = _compute_facecolors_matplotlib(
+        bg_map, faces, coords.shape[0], darkness, alpha
+    )
+    if surf_map is not None:
+        surf_map_faces = _compute_surf_map_faces_matplotlib(
+            surf_map, faces, avg_method, coords.shape[0],
+            face_colors.shape[0]
+        )
+        surf_map_faces, kept_indices, vmin, vmax = _threshold_and_rescale(
+            surf_map_faces, threshold, vmin, vmax
+        )
+        # multiply data with background if indicated
+        if bg_on_data:
+            face_colors[kept_indices] = cmap(surf_map_faces[kept_indices])\
+                * face_colors[kept_indices]
+        else:
+            face_colors[kept_indices] = cmap(surf_map_faces[kept_indices])
+
+        if colorbar:
+            ticks = _get_ticks_matplotlib(vmin, vmax, cbar_tick_format)
+            our_cmap, norm = _get_cmap_matplotlib(cmap, vmin, vmax, threshold)
+            bounds = np.linspace(vmin, vmax, our_cmap.N)
+            # we need to create a proxy mappable
+            proxy_mappable = ScalarMappable(cmap=our_cmap, norm=norm)
+            proxy_mappable.set_array(surf_map_faces)
+            cax, kw = make_axes(axes, location='right', fraction=.15,
+                                shrink=.5, pad=.0, aspect=10.)
+            cbar = figure.colorbar(
+                proxy_mappable, cax=cax, ticks=ticks,
+                boundaries=bounds, spacing='proportional',
+                format=cbar_tick_format, orientation='vertical')
+            _crop_colorbar(cbar, cbar_vmin, cbar_vmax)
+
+        p3dcollec.set_facecolors(face_colors)
+
+    if title is not None:
+        figure.suptitle(title, x=.5, y=.95, fontsize=title_font_size)
+
+    # save figure if output file is given
+    if output_file is not None:
+        figure.savefig(output_file)
+        plt.close(figure)
+    else:
+        return figure
+
+
 @fill_doc
 def plot_surf(surf_mesh, surf_map=None, bg_map=None,
-              hemi='left', view='lateral', cmap=None, colorbar=False,
+              hemi='left', view='lateral', engine='matplotlib',
+              cmap=None, symmetric_cmap=False, colorbar=False,
               avg_method='mean', threshold=None, alpha='auto',
               bg_on_data=False, darkness=1, vmin=None, vmax=None,
-              cbar_vmin=None, cbar_vmax=None, cbar_tick_format='%.2g',
-              title=None, output_file=None, axes=None, figure=None, **kwargs):
+              cbar_vmin=None, cbar_vmax=None, cbar_tick_format="auto",
+              title=None, title_font_size=18, output_file=None, axes=None,
+              figure=None, **kwargs):
     """Plotting of surfaces with optional background and data
 
     .. versionadded:: 0.3
@@ -63,11 +562,47 @@ def plot_surf(surf_mesh, surf_map=None, bg_map=None,
         realistic shading.
     %(hemi)s
     %(view)s
+    engine : {'matplotlib', 'plotly'}, optional
+
+        .. versionadded:: 0.8.2
+
+        Selects which plotting engine will be used by ``plot_surf``.
+        Currently, only ``matplotlib`` and ``plotly`` are supported.
+
+        .. note::
+            To use the ``plotly`` engine, you need to
+            have ``plotly`` installed.
+
+        .. note::
+            To be able to save figures to disk with the
+            ``plotly`` engine, you need to have
+            ``kaleido`` installed.
+
+        .. warning::
+            The ``plotly`` engine is new and experimental.
+            Please report bugs that you may encounter.
+
+        Default='matplotlib'.
     %(cmap)s
         If None, matplotlib default will be chosen.
+    symmetric_cmap : :obj:`bool`, optional
+        Whether to use a symmetric colormap or not.
+
+        .. note::
+            This option is currently only implemented for
+            the ``plotly`` engine.
+
+        .. versionadded:: 0.8.2
+
+        Default=False.
     %(colorbar)s
         Default=False.
     %(avg_method)s
+
+        .. note::
+            This option is currently only implemented for the
+            ``matplotlib`` engine.
+
         Default='mean'.
 
     threshold : a number or None, default is None.
@@ -80,27 +615,63 @@ def plot_surf(surf_mesh, surf_map=None, bg_map=None,
         If 'auto' is chosen, alpha will default to .5 when no bg_map
         is passed and to 1 if a bg_map is passed.
         Default='auto'.
+
+        .. note::
+            This option is currently only implemented for the
+            ``matplotlib`` engine.
+
     %(bg_on_data)s
         Default=False.
     %(darkness)s
         Default=1.
+
+        .. note::
+            This option is currently only implemented for the
+            ``matplotlib`` engine.
+
     %(vmin)s
     %(vmax)s
     cbar_vmin, cbar_vmax : float, float, optional
         Lower / upper bounds for the colorbar.
         If None, the values will be set from the data.
         Default values are None.
+
+        .. note::
+            This option is currently only implemented for the
+            ``matplotlib`` engine.
+
     %(cbar_tick_format)s
-        Default='%%.2g' for scientific notation.
+        Default="auto" which will select:
+
+            - '%%.2g' (scientific notation) with ``matplotlib`` engine.
+            - '.1f' (rounded floats) with ``plotly`` engine.
+
+        .. versionadded:: 0.7.1
+
     %(title)s
+    title_font_size : :obj:`int`, optional
+        Size of the title font.
+
+        .. versionadded:: 0.8.2
+
+        Default=18.
     %(output_file)s
     axes : instance of matplotlib axes, None, optional
         The axes instance to plot to. The projection must be '3d' (e.g.,
         `figure, axes = plt.subplots(subplot_kw={'projection': '3d'})`,
         where axes should be passed.).
         If None, a new axes is created.
+
+        .. note::
+            This option is currently only implemented for the
+            ``matplotlib`` engine.
+
     %(figure)s
 
+        .. note::
+            This option is currently only implemented for the
+            ``matplotlib`` engine.
+
     See Also
     --------
     nilearn.datasets.fetch_surf_fsaverage : For surface data object to be
@@ -115,237 +686,33 @@ def plot_surf(surf_mesh, surf_map=None, bg_map=None,
     nilearn.surface.vol_to_surf : For info on the generation of surfaces.
 
     """
-    _default_figsize = [4, 4]
-
-    # load mesh and derive axes limits
-    mesh = load_surf_mesh(surf_mesh)
-    coords, faces = mesh[0], mesh[1]
-    limits = [coords.min(), coords.max()]
-
-    # set view
-    if hemi == 'right':
-        if view == 'lateral':
-            elev, azim = 0, 0
-        elif view == 'medial':
-            elev, azim = 0, 180
-        elif view == 'dorsal':
-            elev, azim = 90, 0
-        elif view == 'ventral':
-            elev, azim = 270, 0
-        elif view == 'anterior':
-            elev, azim = 0, 90
-        elif view == 'posterior':
-            elev, azim = 0, 270
-        else:
-            raise ValueError('view must be one of lateral, medial, '
-                             'dorsal, ventral, anterior, or posterior')
-    elif hemi == 'left':
-        if view == 'medial':
-            elev, azim = 0, 0
-        elif view == 'lateral':
-            elev, azim = 0, 180
-        elif view == 'dorsal':
-            elev, azim = 90, 0
-        elif view == 'ventral':
-            elev, azim = 270, 0
-        elif view == 'anterior':
-            elev, azim = 0, 90
-        elif view == 'posterior':
-            elev, azim = 0, 270
-        else:
-            raise ValueError('view must be one of lateral, medial, '
-                             'dorsal, ventral, anterior, or posterior')
-    else:
-        raise ValueError('hemi must be one of right or left')
-
-    # set alpha if in auto mode
-    if alpha == 'auto':
-        if bg_map is None:
-            alpha = .5
-        else:
-            alpha = 1
-
-    # if no cmap is given, set to matplotlib default
-    if cmap is None:
-        cmap = plt.cm.get_cmap(plt.rcParamsDefault['image.cmap'])
-    else:
-        # if cmap is given as string, translate to matplotlib cmap
-        if isinstance(cmap, str):
-            cmap = plt.cm.get_cmap(cmap)
-
-    figsize = _default_figsize
-    # Leave space for colorbar
-    if colorbar:
-        figsize[0] += .7
-    # initiate figure and 3d axes
-    if axes is None:
-        if figure is None:
-            figure = plt.figure(figsize=figsize)
-        axes = figure.add_axes((0, 0, 1, 1), projection="3d")
-    else:
-        if figure is None:
-            figure = axes.get_figure()
-    axes.set_xlim(*limits)
-    axes.set_ylim(*limits)
-    axes.view_init(elev=elev, azim=azim)
-    axes.set_axis_off()
-
-    # plot mesh without data
-    p3dcollec = axes.plot_trisurf(coords[:, 0], coords[:, 1], coords[:, 2],
-                                  triangles=faces, linewidth=0.,
-                                  antialiased=False,
-                                  color='white')
-
-    # reduce viewing distance to remove space around mesh
-    axes.dist = 8
-
-    # set_facecolors function of Poly3DCollection is used as passing the
-    # facecolors argument to plot_trisurf does not seem to work
-    face_colors = np.ones((faces.shape[0], 4))
-
-    if bg_map is None:
-        bg_data = np.ones(coords.shape[0]) * 0.5
-
-    else:
-        bg_data = load_surf_data(bg_map)
-        if bg_data.shape[0] != coords.shape[0]:
-            raise ValueError('The bg_map does not have the same number '
-                             'of vertices as the mesh.')
-
-    bg_faces = np.mean(bg_data[faces], axis=1)
-    if bg_faces.min() != bg_faces.max():
-        bg_faces = bg_faces - bg_faces.min()
-        bg_faces = bg_faces / bg_faces.max()
-    # control background darkness
-    bg_faces *= darkness
-    face_colors = plt.cm.gray_r(bg_faces)
-
-    # modify alpha values of background
-    face_colors[:, 3] = alpha * face_colors[:, 3]
-    # should it be possible to modify alpha of surf data as well?
-
-    if surf_map is not None:
-        surf_map_data = load_surf_data(surf_map)
-        if surf_map_data.ndim != 1:
-            raise ValueError('surf_map can only have one dimension but has'
-                             '%i dimensions' % surf_map_data.ndim)
-        if surf_map_data.shape[0] != coords.shape[0]:
-            raise ValueError('The surf_map does not have the same number '
-                             'of vertices as the mesh.')
-
-        # create face values from vertex values by selected avg methods
-        if avg_method == 'mean':
-            surf_map_faces = np.mean(surf_map_data[faces], axis=1)
-        elif avg_method == 'median':
-            surf_map_faces = np.median(surf_map_data[faces], axis=1)
-        elif avg_method == 'min':
-            surf_map_faces = np.min(surf_map_data[faces], axis=1)
-        elif avg_method == 'max':
-            surf_map_faces = np.max(surf_map_data[faces], axis=1)
-        elif callable(avg_method):
-            surf_map_faces = np.apply_along_axis(
-                avg_method, 1, surf_map_data[faces]
-            )
-
-            ## check that surf_map_faces has the same length as face_colors
-            if surf_map_faces.shape != (face_colors.shape[0],):
-                raise ValueError(
-                    'Array computed with the custom function '
-                    'from avg_method does not have the correct shape: '
-                    '{} != {}'.format(
-                        surf_map_faces.shape[0],
-                        face_colors.shape[0]
-                    )
-                )
-
-            ## check that dtype is either int or float
-            if not (
-                "int" in str(surf_map_faces.dtype) or
-                "float" in str(surf_map_faces.dtype)
-            ):
-                raise ValueError(
-                    'Array computed with the custom function '
-                    'from avg_method should be an array of numbers '
-                    '(int or float)'
-                )
-
-        else:
-            raise ValueError(
-                "avg_method should be either "
-                "['mean', 'median', 'max', 'min'] "
-                "or a custom function"
-            )
-
-        # if no vmin/vmax are passed figure them out from data
-        if vmin is None:
-            vmin = np.nanmin(surf_map_faces)
-        if vmax is None:
-            vmax = np.nanmax(surf_map_faces)
-
-        # threshold if indicated
-        if threshold is None:
-            # If no thresholding and nans, filter them out
-            kept_indices = np.where(
-                            np.logical_not(
-                                np.isnan(surf_map_faces)))[0]
-        else:
-            kept_indices = np.where(np.abs(surf_map_faces) >= threshold)[0]
-
-        surf_map_faces = surf_map_faces - vmin
-        surf_map_faces = surf_map_faces / (vmax - vmin)
-
-        # multiply data with background if indicated
-        if bg_on_data:
-            face_colors[kept_indices] = cmap(surf_map_faces[kept_indices])\
-                * face_colors[kept_indices]
-        else:
-            face_colors[kept_indices] = cmap(surf_map_faces[kept_indices])
-
-        if colorbar:
-            our_cmap = get_cmap(cmap)
-            norm = Normalize(vmin=vmin, vmax=vmax)
-
-            # Default number of ticks is 5...
-            nb_ticks = 5
-            # ...unless we are dealing with integers with a small range
-            # in this case, we reduce the number of ticks
-            if cbar_tick_format == "%i" and vmax - vmin < nb_ticks:
-                ticks = np.arange(vmin, vmax + 1)
-            else:
-                ticks = np.linspace(vmin, vmax, nb_ticks)
-            bounds = np.linspace(vmin, vmax, our_cmap.N)
-            if threshold is not None:
-                cmaplist = [our_cmap(i) for i in range(our_cmap.N)]
-                # set colors to grey for absolute values < threshold
-                istart = int(norm(-threshold, clip=True) * (our_cmap.N - 1))
-                istop = int(norm(threshold, clip=True) * (our_cmap.N - 1))
-                for i in range(istart, istop):
-                    cmaplist[i] = (0.5, 0.5, 0.5, 1.)
-                our_cmap = LinearSegmentedColormap.from_list(
-                    'Custom cmap', cmaplist, our_cmap.N)
-
-            # we need to create a proxy mappable
-            proxy_mappable = ScalarMappable(cmap=our_cmap, norm=norm)
-            proxy_mappable.set_array(surf_map_faces)
-            cax, kw = make_axes(axes, location='right', fraction=.15,
-                                shrink=.5, pad=.0, aspect=10.)
-            cbar = figure.colorbar(
-                proxy_mappable, cax=cax, ticks=ticks,
-                boundaries=bounds, spacing='proportional',
-                format=cbar_tick_format, orientation='vertical')
-            _crop_colorbar(cbar, cbar_vmin, cbar_vmax)
-
-        p3dcollec.set_facecolors(face_colors)
-
-    if title is not None:
-        plt.suptitle(title, x=.5, y=.95)
-
-    # save figure if output file is given
-    if output_file is not None:
-        figure.savefig(output_file)
-        plt.close(figure)
+    coords, faces = load_surf_mesh(surf_mesh)
+    if engine == 'matplotlib':
+        if cbar_tick_format == "auto":
+            cbar_tick_format = '%.2g'
+        fig = _plot_surf_matplotlib(
+            coords, faces, surf_map=surf_map, bg_map=bg_map, hemi=hemi,
+            view=view, cmap=cmap, colorbar=colorbar, avg_method=avg_method,
+            threshold=threshold, alpha=alpha, bg_on_data=bg_on_data,
+            darkness=darkness, vmin=vmin, vmax=vmax, cbar_vmin=cbar_vmin,
+            cbar_vmax=cbar_vmax, cbar_tick_format=cbar_tick_format,
+            title=title, title_font_size=title_font_size,
+            output_file=output_file, axes=axes, figure=figure, **kwargs)
+    elif engine == 'plotly':
+        if cbar_tick_format == "auto":
+            cbar_tick_format = ".1f"
+        fig = _plot_surf_plotly(
+            coords, faces, surf_map=surf_map, bg_map=bg_map, view=view,
+            hemi=hemi, cmap=cmap, symmetric_cmap=symmetric_cmap,
+            colorbar=colorbar, threshold=threshold, vmin=vmin,
+            vmax=vmax, cbar_vmin=cbar_vmin, cbar_vmax=cbar_vmax,
+            cbar_tick_format=cbar_tick_format, title=title,
+            title_font_size=title_font_size, output_file=output_file)
     else:
-        return figure
+        raise ValueError(f"Unknown plotting engine {engine}. "
+                         "Please use either 'matplotlib' or "
+                         "'plotly'.")
+    return fig
 
 
 def _get_faces_on_edge(faces, parc_idx):
@@ -506,10 +873,12 @@ def plot_surf_contours(surf_mesh, roi_map, axes=None, figure=None, levels=None,
 
 @fill_doc
 def plot_surf_stat_map(surf_mesh, stat_map, bg_map=None,
-                       hemi='left', view='lateral', threshold=None,
-                       alpha='auto', vmax=None, cmap='cold_hot',
-                       colorbar=True, symmetric_cbar="auto", bg_on_data=False,
-                       darkness=1, title=None, output_file=None, axes=None,
+                       hemi='left', view='lateral', engine='matplotlib',
+                       threshold=None, alpha='auto', vmax=None,
+                       cmap='cold_hot', colorbar=True,
+                       symmetric_cbar="auto", cbar_tick_format="auto",
+                       bg_on_data=False, darkness=1, title=None,
+                       title_font_size=18, output_file=None, axes=None,
                        figure=None, **kwargs):
     """Plotting a stats map on a surface mesh with optional background
 
@@ -539,12 +908,41 @@ def plot_surf_stat_map(surf_mesh, stat_map, bg_map=None,
         realistic shading.
     %(hemi)s
     %(view)s
+    engine : {'matplotlib', 'plotly'}, optional
+
+        .. versionadded:: 0.8.2
+
+        Selects which plotting engine will be used by ``plot_surf_stat_map``.
+        Currently, only ``matplotlib`` and ``plotly`` are supported.
+
+        .. note::
+            To use the ``plotly`` engine you need to
+            have ``plotly`` installed.
+
+        .. note::
+            To be able to save figures to disk with the ``plotly``
+            engine you need to have ``kaleido`` installed.
+
+        .. warning::
+            The ``plotly`` engine is new and experimental.
+            Please report bugs that you may encounter.
+
+        Default='matplotlib'.
+
     threshold : a number or None, optional
         If None is given, the image is not thresholded.
         If a number is given, it is used to threshold the image,
         values below the threshold (in absolute value) are plotted
         as transparent.
     %(cmap)s
+    %(cbar_tick_format)s
+        Default="auto" which will select:
+
+            - '%%.2g' (scientific notation) with ``matplotlib`` engine.
+            - '.1f' (rounded floats) with ``plotly`` engine.
+
+        .. versionadded:: 0.7.1
+
     %(colorbar)s
 
         .. note::
@@ -557,6 +955,11 @@ def plot_surf_stat_map(surf_mesh, stat_map, bg_map=None,
         If 'auto' is chosen, alpha will default to .5 when no bg_map is
         passed and to 1 if a bg_map is passed.
         Default='auto'.
+
+        .. note::
+            This option is currently only implemented for the
+            ``matplotlib`` engine.
+
     %(vmax)s
     %(symmetric_cbar)s
         Default='auto'.
@@ -564,15 +967,38 @@ def plot_surf_stat_map(surf_mesh, stat_map, bg_map=None,
         Default=False.
     %(darkness)s
         Default=1.
+
+        .. note::
+            This option is currently only implemented for the
+            ``matplotlib`` engine.
+
     %(title)s
+    title_font_size : :obj:`int`, optional
+        Size of the title font.
+
+        .. versionadded:: 0.8.2
+
+        Default=18.
     %(output_file)s
     axes : instance of matplotlib axes, None, optional
         The axes instance to plot to. The projection must be '3d' (e.g.,
         `figure, axes = plt.subplots(subplot_kw={'projection': '3d'})`,
         where axes should be passed.).
         If None, a new axes is created.
+
+        .. note::
+            This option is currently only implemented for the
+            ``matplotlib`` engine.
+
     %(figure)s
 
+        .. note::
+            This option is currently only implemented for the
+            ``matplotlib`` engine.
+
+    kwargs : dict, optional
+        Keyword arguments passed to :func:`nilearn.plotting.plot_surf`.
+
     See Also
     --------
     nilearn.datasets.fetch_surf_fsaverage: For surface data object to be
@@ -591,12 +1017,15 @@ def plot_surf_stat_map(surf_mesh, stat_map, bg_map=None,
         loaded_stat_map, vmax, symmetric_cbar, kwargs)
 
     display = plot_surf(
-        surf_mesh, surf_map=loaded_stat_map, bg_map=bg_map, hemi=hemi, view=view,
-        avg_method='mean', threshold=threshold, cmap=cmap, colorbar=colorbar,
-        alpha=alpha, bg_on_data=bg_on_data, darkness=darkness, vmax=vmax,
-        vmin=vmin, title=title, output_file=output_file, axes=axes,
-        figure=figure, cbar_vmin=cbar_vmin, cbar_vmax=cbar_vmax, **kwargs)
-
+        surf_mesh, surf_map=loaded_stat_map, bg_map=bg_map, hemi=hemi,
+        view=view, engine=engine, avg_method='mean', threshold=threshold,
+        cmap=cmap, symmetric_cmap=True, colorbar=colorbar,
+        cbar_tick_format=cbar_tick_format, alpha=alpha,
+        bg_on_data=bg_on_data, darkness=darkness, vmax=vmax, vmin=vmin,
+        title=title, title_font_size=title_font_size, output_file=output_file,
+        axes=axes, figure=figure, cbar_vmin=cbar_vmin,
+        cbar_vmax=cbar_vmax, **kwargs
+    )
     return display
 
 
@@ -826,10 +1255,12 @@ def plot_img_on_surf(stat_map, surf_mesh='fsaverage5', mask_img=None,
 
 @fill_doc
 def plot_surf_roi(surf_mesh, roi_map, bg_map=None,
-                  hemi='left', view='lateral', threshold=1e-14,
-                  alpha='auto', vmin=None, vmax=None, cmap='gist_ncar',
-                  cbar_tick_format="%i", bg_on_data=False, darkness=1,
-                  title=None, output_file=None, axes=None, figure=None, **kwargs):
+                  hemi='left', view='lateral', engine='matplotlib',
+                  threshold=1e-14, alpha='auto', vmin=None, vmax=None,
+                  cmap='gist_ncar', cbar_tick_format="auto",
+                  bg_on_data=False, darkness=1, title=None,
+                  title_font_size=18, output_file=None, axes=None,
+                  figure=None, **kwargs):
     """ Plotting ROI on a surface mesh with optional background
 
     .. versionadded:: 0.3
@@ -858,6 +1289,27 @@ def plot_surf_roi(surf_mesh, roi_map, bg_map=None,
         stat_map in greyscale, most likely a sulcal depth map for
         realistic shading.
     %(view)s
+    engine : {'matplotlib', 'plotly'}, optional
+
+        .. versionadded:: 0.8.2
+
+        Selects which plotting engine will be used by ``plot_surf_roi``.
+        Currently, only ``matplotlib`` and ``plotly`` are supported.
+
+        .. note::
+            To use the ``plotly`` engine you need to have
+            ``plotly`` installed.
+
+        .. note::
+            To be able to save figures to disk with ``plotly`` engine
+            you need to have ``kaleido`` installed.
+
+        .. warning::
+            The ``plotly`` engine is new and experimental.
+            Please report bugs that you may encounter.
+
+        Default='matplotlib'.
+
     threshold : a number or None, optional
         Threshold regions that are labelled 0.
         If you want to use 0 as a label, set threshold to None.
@@ -865,24 +1317,58 @@ def plot_surf_roi(surf_mesh, roi_map, bg_map=None,
     %(cmap)s
         Default='gist_ncar'.
     %(cbar_tick_format)s
-        Default='%%i' for integers.
+        Default="auto" which defaults to integers format:
+
+            - "%%i" for ``matplotlib`` engine.
+            - "." for ``plotly`` engine.
+
+        .. versionadded:: 0.7.1
+
     alpha : float or 'auto', optional
         Alpha level of the mesh (not the stat_map). If default,
         alpha will default to .5 when no bg_map is passed
         and to 1 if a bg_map is passed.
         Default='auto'.
+
+        .. note::
+            This option is currently only implemented for the
+            ``matplotlib`` engine.
+
     %(bg_on_data)s
         Default=False.
     %(darkness)s
         Default=1.
+
+        .. note::
+            This option is currently only implemented for the
+            ``matplotlib`` engine.
+
     %(title)s
+    title_font_size : :obj:`int`, optional
+        Size of the title font.
+
+        .. versionadded:: 0.8.2
+
+        Default=18.
     %(output_file)s
     axes : Axes instance or None, optional
         The axes instance to plot to. The projection must be '3d' (e.g.,
         `plt.subplots(subplot_kw={'projection': '3d'})`).
         If None, a new axes is created.
+
+        .. note::
+            This option is currently only implemented for the
+            ``matplotlib`` engine.
+
     %(figure)s
 
+        .. note::
+            This option is currently only implemented for the
+            ``matplotlib`` engine.
+
+    kwargs : dict, optional
+        Keyword arguments passed to :func:`nilearn.plotting.plot_surf`.
+
     See Also
     --------
     nilearn.datasets.fetch_surf_fsaverage: For surface data object to be
@@ -914,12 +1400,16 @@ def plot_surf_roi(surf_mesh, roi_map, bg_map=None,
                          'roi_map = np.zeros(n_vertices)\n'
                          'roi_map[roi_idx] = 1')
 
+    if cbar_tick_format == "auto":
+        cbar_tick_format = "." if engine == "plotly" else "%i"
     display = plot_surf(mesh, surf_map=roi, bg_map=bg_map,
-                        hemi=hemi, view=view, avg_method='median',
-                        threshold=threshold, cmap=cmap,
-                        cbar_tick_format=cbar_tick_format, alpha=alpha,
-                        bg_on_data=bg_on_data, darkness=darkness,
-                        vmin=vmin, vmax=vmax, title=title,
+                        hemi=hemi, view=view, engine=engine,
+                        avg_method='median', threshold=threshold,
+                        cmap=cmap, symmetric_cmap=False,
+                        cbar_tick_format=cbar_tick_format,
+                        alpha=alpha, bg_on_data=bg_on_data,
+                        darkness=darkness, vmin=vmin, vmax=vmax,
+                        title=title, title_font_size=title_font_size,
                         output_file=output_file, axes=axes,
                         figure=figure, **kwargs)
 


Test patch:
diff --git a/nilearn/plotting/tests/test_surf_plotting.py b/nilearn/plotting/tests/test_surf_plotting.py
--- a/nilearn/plotting/tests/test_surf_plotting.py
+++ b/nilearn/plotting/tests/test_surf_plotting.py
@@ -14,37 +14,148 @@
 from nilearn.surface import load_surf_mesh
 from nilearn.surface.testing_utils import generate_surf
 from numpy.testing import assert_array_equal
+from nilearn.plotting.surf_plotting import VALID_HEMISPHERES, VALID_VIEWS
+
+
+EXPECTED_CAMERAS_PLOTLY = {"left": {"anterior": "anterior",
+                                    "posterior": "posterior",
+                                    "medial": "right",
+                                    "lateral": "left",
+                                    "dorsal": "dorsal",
+                                    "ventral": "ventral"},
+                           "right": {"anterior": "anterior",
+                                     "posterior": "posterior",
+                                     "medial": "left",
+                                     "lateral": "right",
+                                     "dorsal": "dorsal",
+                                     "ventral": "ventral"}}
+
+
+EXPECTED_VIEW_MATPLOTLIB = {"left": {"anterior": (0, 90),
+                                     "posterior": (0, 270),
+                                     "medial": (0, 0),
+                                     "lateral": (0, 180),
+                                     "dorsal": (90, 0),
+                                     "ventral": (270, 0)},
+                            "right": {"anterior": (0, 90),
+                                      "posterior": (0, 270),
+                                      "medial": (0, 180),
+                                      "lateral": (0, 0),
+                                      "dorsal": (90, 0),
+                                      "ventral": (270, 0)}}
+
+
+@pytest.fixture
+def expected_cameras_plotly(hemi, view):
+    return EXPECTED_CAMERAS_PLOTLY[hemi][view]
+
+
+@pytest.mark.parametrize("hemi", VALID_HEMISPHERES)
+@pytest.mark.parametrize("view", VALID_VIEWS)
+def test_set_view_plot_surf_plotly(hemi, view, expected_cameras_plotly):
+    from nilearn.plotting.surf_plotting import _set_view_plot_surf_plotly
+    assert _set_view_plot_surf_plotly(hemi, view) == expected_cameras_plotly
+
+
+@pytest.fixture
+def expected_view_matplotlib(hemi, view):
+    return EXPECTED_VIEW_MATPLOTLIB[hemi][view]
+
+
+@pytest.mark.parametrize("hemi", VALID_HEMISPHERES)
+@pytest.mark.parametrize("view", VALID_VIEWS)
+def test_set_view_plot_surf_matplotlib(hemi, view, expected_view_matplotlib):
+    from nilearn.plotting.surf_plotting import _set_view_plot_surf_matplotlib
+    assert(_set_view_plot_surf_matplotlib(hemi, view)
+           == expected_view_matplotlib)
+
+
+def test_set_view_plot_surf_errors():
+    from nilearn.plotting.surf_plotting import (_set_view_plot_surf_matplotlib,
+                                                _set_view_plot_surf_plotly)
+    with pytest.raises(ValueError,
+                       match="hemi must be one of"):
+        _set_view_plot_surf_matplotlib("foo", "medial")
+        _set_view_plot_surf_plotly("bar", "anterior")
+    with pytest.raises(ValueError,
+                       match="view must be one of"):
+        _set_view_plot_surf_matplotlib("left", "foo")
+        _set_view_plot_surf_matplotlib("right", "bar")
+        _set_view_plot_surf_plotly("left", "foo")
+        _set_view_plot_surf_plotly("right", "bar")
+
+
+def test_configure_title_plotly():
+    from nilearn.plotting.surf_plotting import _configure_title_plotly
+    assert _configure_title_plotly(None, None) == dict()
+    assert _configure_title_plotly(None, 22) == dict()
+    config = _configure_title_plotly("Test Title", 22, color="green")
+    assert config["text"] == "Test Title"
+    assert config["x"] == 0.5
+    assert config["y"] == 0.96
+    assert config["xanchor"] == "center"
+    assert config["yanchor"] == "top"
+    assert config["font"]["size"] == 22
+    assert config["font"]["color"] == "green"
+
+
+@pytest.mark.parametrize("data,expected",
+                         [(np.linspace(0, 1, 100), (0, 1)),
+                          (np.linspace(-.7, -.01, 40), (-.7, -.01))])
+def test_get_bounds(data, expected):
+    from nilearn.plotting.surf_plotting import _get_bounds
+    assert _get_bounds(data) == expected
+    assert _get_bounds(data, vmin=.2) == (.2, expected[1])
+    assert _get_bounds(data, vmax=.8) == (expected[0], .8)
+    assert _get_bounds(data, vmin=.1, vmax=.8) == (.1, .8)
+
+
+def test_plot_surf_engine_error():
+    mesh = generate_surf()
+    with pytest.raises(ValueError,
+                       match="Unknown plotting engine"):
+        plot_surf(mesh, engine="foo")
 
 
-def test_plot_surf():
+@pytest.mark.parametrize("engine", ["matplotlib", "plotly"])
+def test_plot_surf(engine, tmp_path):
     mesh = generate_surf()
     rng = np.random.RandomState(42)
     bg = rng.standard_normal(size=mesh[0].shape[0])
 
     # Plot mesh only
-    plot_surf(mesh)
+    plot_surf(mesh, engine=engine)
 
     # Plot mesh with background
-    plot_surf(mesh, bg_map=bg)
-    plot_surf(mesh, bg_map=bg, darkness=0.5)
-    plot_surf(mesh, bg_map=bg, alpha=0.5)
+    plot_surf(mesh, bg_map=bg, engine=engine)
+    plot_surf(mesh, bg_map=bg, darkness=0.5, engine=engine)
+    plot_surf(mesh, bg_map=bg, alpha=0.5,
+              output_file=tmp_path / 'tmp.png', engine=engine)
 
     # Plot different views
-    plot_surf(mesh, bg_map=bg, hemi='right')
-    plot_surf(mesh, bg_map=bg, view='medial')
-    plot_surf(mesh, bg_map=bg, hemi='right', view='medial')
+    plot_surf(mesh, bg_map=bg, hemi='right', engine=engine)
+    plot_surf(mesh, bg_map=bg, view='medial', engine=engine)
+    plot_surf(mesh, bg_map=bg, hemi='right', view='medial', engine=engine)
 
     # Plot with colorbar
-    plot_surf(mesh, bg_map=bg, colorbar=True)
+    plot_surf(mesh, bg_map=bg, colorbar=True, engine=engine)
     plot_surf(mesh, bg_map=bg, colorbar=True, cbar_vmin=0,
-              cbar_vmax=150, cbar_tick_format="%i")
+              cbar_vmax=150, cbar_tick_format="%i", engine=engine)
+    # Save execution time and memory
+    plt.close()
 
     # Plot with title
-    display = plot_surf(mesh, bg_map=bg, title='Test title')
-    assert display._suptitle._text == 'Test title'
-    assert display._suptitle._x == .5
-    assert display._suptitle._y == .95
+    display = plot_surf(mesh, bg_map=bg, title='Test title',
+                        engine=engine)
+    if engine == 'matplotlib':
+        assert display._suptitle._text == 'Test title'
+        assert display._suptitle._x == .5
+        assert display._suptitle._y == .95
+
 
+def test_plot_surf_avg_method():
+    mesh = generate_surf()
+    rng = np.random.RandomState(42)
     # Plot with avg_method
     ## Test all built-in methods and check
     mapp = rng.standard_normal(size=mesh[0].shape[0])
@@ -52,7 +163,9 @@ def test_plot_surf():
     coords, faces = mesh_[0], mesh_[1]
 
     for method in ['mean', 'median', 'min', 'max']:
-        display = plot_surf(mesh, surf_map=mapp, avg_method=method)
+        display = plot_surf(mesh, surf_map=mapp,
+                            avg_method=method,
+                            engine='matplotlib')
         if method == 'mean':
             agg_faces = np.mean(mapp[faces], axis=1)
         elif method == 'median':
@@ -70,51 +183,62 @@ def test_plot_surf():
             cmap(agg_faces),
             display._axstack.as_list()[0].collections[0]._facecolors
         )
-
     ## Try custom avg_method
     def custom_avg_function(vertices):
         return vertices[0] * vertices[1] * vertices[2]
     plot_surf(
         mesh,
         surf_map=rng.standard_normal(size=mesh[0].shape[0]),
-        avg_method=custom_avg_function
+        avg_method=custom_avg_function,
+        engine='matplotlib',
     )
-
     # Save execution time and memory
     plt.close()
 
 
-def test_plot_surf_error():
+@pytest.mark.parametrize("engine", ["matplotlib", "plotly"])
+def test_plot_surf_error(engine):
     mesh = generate_surf()
     rng = np.random.RandomState(42)
 
     # Wrong inputs for view or hemi
     with pytest.raises(ValueError, match='view must be one of'):
-        plot_surf(mesh, view='middle')
+        plot_surf(mesh, view='middle', engine=engine)
     with pytest.raises(ValueError, match='hemi must be one of'):
-        plot_surf(mesh, hemi='lft')
+        plot_surf(mesh, hemi='lft', engine=engine)
 
     # Wrong size of background image
     with pytest.raises(
             ValueError,
             match='bg_map does not have the same number of vertices'):
-        plot_surf(mesh, bg_map=rng.standard_normal(size=mesh[0].shape[0] - 1))
+        plot_surf(mesh,
+                  bg_map=rng.standard_normal(size=mesh[0].shape[0] - 1),
+                  engine=engine
+                  )
 
     # Wrong size of surface data
     with pytest.raises(
         ValueError, match="surf_map does not have the same number of vertices"
     ):
         plot_surf(
-            mesh, surf_map=rng.standard_normal(size=mesh[0].shape[0] + 1)
+            mesh,
+            surf_map=rng.standard_normal(size=mesh[0].shape[0] + 1),
+            engine=engine
         )
 
     with pytest.raises(
-        ValueError, match="surf_map can only have one dimension"
+        ValueError, match="'surf_map' can only have one dimension"
     ):
         plot_surf(
-            mesh, surf_map=rng.standard_normal(size=(mesh[0].shape[0], 2))
+            mesh,
+            surf_map=rng.standard_normal(size=(mesh[0].shape[0], 2)),
+            engine=engine
         )
 
+
+def test_plot_surf_avg_method_errors():
+    mesh = generate_surf()
+    rng = np.random.RandomState(42)
     with pytest.raises(
         ValueError,
         match=(
@@ -126,7 +250,12 @@ def test_plot_surf_error():
         def custom_avg_function(vertices):
             return [vertices[0] * vertices[1], vertices[2]]
 
-        plot_surf(mesh, surf_map=rng.standard_normal(size=mesh[0].shape[0]), avg_method=custom_avg_function)
+        plot_surf(mesh,
+                  surf_map=rng.standard_normal(
+                      size=mesh[0].shape[0]),
+                  avg_method=custom_avg_function,
+                  engine='matplotlib'
+                  )
 
     with pytest.raises(
         ValueError,
@@ -138,7 +267,19 @@ def custom_avg_function(vertices):
     ):
         custom_avg_function = dict()
 
-        plot_surf(mesh, surf_map=rng.standard_normal(size=mesh[0].shape[0]), avg_method=custom_avg_function)
+        plot_surf(mesh,
+                  surf_map=rng.standard_normal(
+                      size=mesh[0].shape[0]),
+                  avg_method=custom_avg_function,
+                  engine='matplotlib'
+                  )
+
+        plot_surf(mesh,
+                  surf_map=rng.standard_normal(
+                      size=mesh[0].shape[0]),
+                  avg_method="foo",
+                  engine='matplotlib'
+                  )
 
     with pytest.raises(
         ValueError,
@@ -151,25 +292,33 @@ def custom_avg_function(vertices):
         def custom_avg_function(vertices):
             return "string"
 
-        plot_surf(mesh, surf_map=rng.standard_normal(size=mesh[0].shape[0]), avg_method=custom_avg_function)
+        plot_surf(mesh,
+                  surf_map=rng.standard_normal(
+                      size=mesh[0].shape[0]),
+                  avg_method=custom_avg_function,
+                  engine='matplotlib'
+                  )
+
 
-def test_plot_surf_stat_map():
+@pytest.mark.parametrize("engine", ["matplotlib", "plotly"])
+def test_plot_surf_stat_map(engine):
     mesh = generate_surf()
     rng = np.random.RandomState(42)
     bg = rng.standard_normal(size=mesh[0].shape[0])
     data = 10 * rng.standard_normal(size=mesh[0].shape[0])
 
     # Plot mesh with stat map
-    plot_surf_stat_map(mesh, stat_map=data)
-    plot_surf_stat_map(mesh, stat_map=data, colorbar=True)
-    plot_surf_stat_map(mesh, stat_map=data, alpha=1)
+    plot_surf_stat_map(mesh, stat_map=data, engine=engine)
+    plot_surf_stat_map(mesh, stat_map=data, colorbar=True, engine=engine)
+    plot_surf_stat_map(mesh, stat_map=data, alpha=1, engine=engine)
 
     # Plot mesh with background and stat map
-    plot_surf_stat_map(mesh, stat_map=data, bg_map=bg)
+    plot_surf_stat_map(mesh, stat_map=data, bg_map=bg, engine=engine)
     plot_surf_stat_map(mesh, stat_map=data, bg_map=bg,
-                       bg_on_data=True, darkness=0.5)
+                       bg_on_data=True, darkness=0.5,
+                       engine=engine)
     plot_surf_stat_map(mesh, stat_map=data, bg_map=bg, colorbar=True,
-                       bg_on_data=True, darkness=0.5)
+                       bg_on_data=True, darkness=0.5, engine=engine)
 
     # Plot with title
     display = plot_surf_stat_map(mesh, stat_map=data, bg_map=bg,
@@ -181,23 +330,33 @@ def test_plot_surf_stat_map():
     # Apply threshold
     plot_surf_stat_map(mesh, stat_map=data, bg_map=bg,
                        bg_on_data=True, darkness=0.5,
-                       threshold=0.3)
+                       threshold=0.3, engine=engine)
     plot_surf_stat_map(mesh, stat_map=data, bg_map=bg, colorbar=True,
                        bg_on_data=True, darkness=0.5,
-                       threshold=0.3)
+                       threshold=0.3, engine=engine)
 
     # Change colorbar tick format
     plot_surf_stat_map(mesh, stat_map=data, bg_map=bg, colorbar=True,
-                       bg_on_data=True, darkness=0.5, cbar_tick_format="%.2g")
+                       bg_on_data=True, darkness=0.5, cbar_tick_format="%.2g",
+                       engine=engine)
 
     # Change vmax
-    plot_surf_stat_map(mesh, stat_map=data, vmax=5)
-    plot_surf_stat_map(mesh, stat_map=data, vmax=5, colorbar=True)
+    plot_surf_stat_map(mesh, stat_map=data, vmax=5, engine=engine)
+    plot_surf_stat_map(mesh, stat_map=data, vmax=5,
+                       colorbar=True, engine=engine)
 
     # Change colormap
-    plot_surf_stat_map(mesh, stat_map=data, cmap='cubehelix')
-    plot_surf_stat_map(mesh, stat_map=data, cmap='cubehelix', colorbar=True)
+    plot_surf_stat_map(mesh, stat_map=data, cmap='cubehelix', engine=engine)
+    plot_surf_stat_map(mesh, stat_map=data, cmap='cubehelix',
+                       colorbar=True, engine=engine)
+
+    plt.close()
+
 
+def test_plot_surf_stat_map_matplotlib_specific():
+    mesh = generate_surf()
+    rng = np.random.RandomState(42)
+    data = 10 * rng.standard_normal(size=mesh[0].shape[0])
     # Plot to axes
     axes = plt.subplots(ncols=2, subplot_kw={'projection': '3d'})[1]
     for ax in axes.flatten():
@@ -260,24 +419,42 @@ def test_plot_surf_stat_map_error():
 
     with pytest.raises(
             ValueError,
-            match='surf_map can only have one dimension'):
+            match="'surf_map' can only have one dimension"):
         plot_surf_stat_map(mesh, stat_map=np.vstack((data, data)).T)
 
 
-def test_plot_surf_roi():
+def _generate_data_test_surf_roi():
     mesh = generate_surf()
     rng = np.random.RandomState(42)
     roi_idx = rng.randint(0, mesh[0].shape[0], size=10)
     roi_map = np.zeros(mesh[0].shape[0])
     roi_map[roi_idx] = 1
     parcellation = rng.uniform(size=mesh[0].shape[0])
+    return mesh, roi_map, parcellation
+
 
+@pytest.mark.parametrize("engine", ["matplotlib", "plotly"])
+def test_plot_surf_roi(engine):
+    mesh, roi_map, parcellation = _generate_data_test_surf_roi()
     # plot roi
-    plot_surf_roi(mesh, roi_map=roi_map)
-    plot_surf_roi(mesh, roi_map=roi_map, colorbar=True)
+    plot_surf_roi(mesh, roi_map=roi_map, engine=engine)
+    plot_surf_roi(mesh, roi_map=roi_map,
+                  colorbar=True, engine=engine)
+    # plot parcellation
+    plot_surf_roi(mesh, roi_map=parcellation, engine=engine)
+    plot_surf_roi(mesh, roi_map=parcellation, colorbar=True,
+                  engine=engine)
+    plot_surf_roi(mesh, roi_map=parcellation, colorbar=True,
+                  cbar_tick_fomat="%f", engine=engine)
+    plt.close()
+
+
+def test_plot_surf_roi_matplotlib_specific():
+    mesh, roi_map, parcellation = _generate_data_test_surf_roi()
     # change vmin, vmax
     img = plot_surf_roi(mesh, roi_map=roi_map, vmin=1.2,
-                        vmax=8.9, colorbar=True)
+                        vmax=8.9, colorbar=True,
+                        engine='matplotlib')
     img.canvas.draw()
     cbar = img.axes[-1]
     cbar_vmin = float(cbar.get_yticklabels()[0].get_text())
@@ -285,33 +462,33 @@ def test_plot_surf_roi():
     assert cbar_vmin == 1.0
     assert cbar_vmax == 8.0
     img2 = plot_surf_roi(mesh, roi_map=roi_map, vmin=1.2,
-                         vmax=8.9, colorbar=True, cbar_tick_format="%.2g")
+                         vmax=8.9, colorbar=True,
+                         cbar_tick_format="%.2g",
+                         engine='matplotlib')
     img2.canvas.draw()
     cbar = img2.axes[-1]
     cbar_vmin = float(cbar.get_yticklabels()[0].get_text())
     cbar_vmax = float(cbar.get_yticklabels()[-1].get_text())
     assert cbar_vmin == 1.2
     assert cbar_vmax == 8.9
-
-    # plot parcellation
-    plot_surf_roi(mesh, roi_map=parcellation)
-    plot_surf_roi(mesh, roi_map=parcellation, colorbar=True)
-    plot_surf_roi(mesh, roi_map=parcellation, colorbar=True, cbar_tick_fomat="%f")
-
     # plot to axes
-    plot_surf_roi(mesh, roi_map=roi_map, ax=None, figure=plt.gcf())
+    plot_surf_roi(mesh, roi_map=roi_map, ax=None,
+                  figure=plt.gcf(), engine='matplotlib')
 
     # plot to axes
     with tempfile.NamedTemporaryFile() as tmp_file:
-        plot_surf_roi(mesh, roi_map=roi_map, ax=plt.gca(), figure=None,
-                      output_file=tmp_file.name)
+        plot_surf_roi(mesh, roi_map=roi_map, ax=plt.gca(),
+                      figure=None, output_file=tmp_file.name,
+                      engine='matplotlib')
     with tempfile.NamedTemporaryFile() as tmp_file:
-        plot_surf_roi(mesh, roi_map=roi_map, ax=plt.gca(), figure=None,
-                      output_file=tmp_file.name, colorbar=True)
+        plot_surf_roi(mesh, roi_map=roi_map, ax=plt.gca(),
+                      figure=None, output_file=tmp_file.name,
+                      colorbar=True, engine='matplotlib')
 
     # Test nans handling
     parcellation[::2] = np.nan
-    img = plot_surf_roi(mesh, roi_map=parcellation)
+    img = plot_surf_roi(mesh, roi_map=parcellation,
+                        engine='matplotlib')
     # Check that the resulting plot facecolors contain no transparent faces
     # (last column equals zero) even though the texture contains nan values
     assert(mesh[1].shape[0] ==
@@ -320,14 +497,15 @@ def test_plot_surf_roi():
     plt.close()
 
 
-def test_plot_surf_roi_error():
+@pytest.mark.parametrize("engine", ["matplotlib", "plotly"])
+def test_plot_surf_roi_error(engine):
     mesh = generate_surf()
     rng = np.random.RandomState(42)
     roi_idx = rng.randint(0, mesh[0].shape[0], size=5)
     with pytest.raises(
             ValueError,
             match='roi_map does not have the same number of vertices'):
-        plot_surf_roi(mesh, roi_map=roi_idx)
+        plot_surf_roi(mesh, roi_map=roi_idx, engine=engine)
 
 
 def _generate_img():