diff --git a/gui/package.json b/gui/package.json
index ae986f8ee0b36b4c2d0eecb58711a13564eb9f8f..0451037b993934a4088ab77f730a6932e61827fe 100644
--- a/gui/package.json
+++ b/gui/package.json
@@ -29,6 +29,7 @@
     "pace": "^0.0.4",
     "pace-js": "^1.0.2",
     "piwik-react-router": "^0.12.1",
+    "plotly.js-cartesian-dist-min": "^1.54.7",
     "qs": "^6.8.0",
     "react": "^16.13.1",
     "react-app-polyfill": "^1.0.1",
diff --git a/gui/public/index.html b/gui/public/index.html
index a111cb993ab9d0a4aec7de0bf0d75733ffe48fcc..bd3ef7cee4c21a5923cf6fabb8e800f1150e33c4 100644
--- a/gui/public/index.html
+++ b/gui/public/index.html
@@ -5,6 +5,9 @@
       window.paceOptions = {
         restartOnPushState: true
       }
+      // This needs to be defined for Plotly.js graphs, see
+      // https://github.com/plotly/plotly.js/blob/master/dist/README.md#partial-bundles
+      window.PlotlyConfig = {MathJaxConfig: 'local'}
     </script>
     <script src="https://unpkg.com/pace-js@1.0.2/pace.min.js"></script>
     <link href="%PUBLIC_URL%/pace.css" rel="stylesheet" />
@@ -38,7 +41,6 @@
 
     <script src="https://polyfill.io/v3/polyfill.min.js?features=es6"></script>
     <script id="MathJax-script" async src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"></script>
-
     <title>NOMAD</title>
   </head>
   <body>
diff --git a/gui/src/components/api.js b/gui/src/components/api.js
index f3fccbfe7a1e1877d3bd89dd3c4dbd560da90a07..cad16c0b45b9735328d6e8d2c69016cd838939d3 100644
--- a/gui/src/components/api.js
+++ b/gui/src/components/api.js
@@ -299,6 +299,86 @@ class Api {
       .finally(this.onFinishLoading)
   }
 
+  async encyclopediaBasic(materialId) {
+    this.onStartLoading()
+    return this.swagger()
+      .then(client => client.apis.encyclopedia.get_material({
+        material_id: materialId
+      }))
+      .catch(handleApiError)
+      .then(response => {
+        const result = response.body || response.text || response.data
+        if (typeof result === 'string') {
+          try {
+            return JSON.parse(result)
+          } catch (e) {
+            try {
+              return JSON.parse(result.replace(/\bNaN\b/g, '"NaN"'))
+            } catch (e) {
+              return result
+            }
+          }
+        } else {
+          return result
+        }
+      })
+      .finally(this.onFinishLoading)
+  }
+
+  async encyclopediaCalculations(materialId) {
+    this.onStartLoading()
+    return this.swagger()
+      .then(client => client.apis.encyclopedia.get_calculations({
+        material_id: materialId
+      }))
+      .catch(handleApiError)
+      .then(response => {
+        const result = response.body || response.text || response.data
+        if (typeof result === 'string') {
+          try {
+            return JSON.parse(result)
+          } catch (e) {
+            try {
+              return JSON.parse(result.replace(/\bNaN\b/g, '"NaN"'))
+            } catch (e) {
+              return result
+            }
+          }
+        } else {
+          return result
+        }
+      })
+      .finally(this.onFinishLoading)
+  }
+
+  async encyclopediaCalculation(materialId, calcId, payload) {
+    this.onStartLoading()
+    return this.swagger()
+      .then(client => client.apis.encyclopedia.get_calculation({
+        material_id: materialId,
+        calc_id: calcId,
+        payload: payload
+      }))
+      .catch(handleApiError)
+      .then(response => {
+        const result = response.body || response.text || response.data
+        if (typeof result === 'string') {
+          try {
+            return JSON.parse(result)
+          } catch (e) {
+            try {
+              return JSON.parse(result.replace(/\bNaN\b/g, '"NaN"'))
+            } catch (e) {
+              return result
+            }
+          }
+        } else {
+          return result
+        }
+      })
+      .finally(this.onFinishLoading)
+  }
+
   async calcProcLog(uploadId, calcId) {
     this.onStartLoading()
     return this.swagger()
diff --git a/gui/src/components/archive/ArchiveBrowser.js b/gui/src/components/archive/ArchiveBrowser.js
index ab129ac57154202b651566c217b70dde7eff4b86..600cc6a83227db976fda06725d66f609a6e30417 100644
--- a/gui/src/components/archive/ArchiveBrowser.js
+++ b/gui/src/components/archive/ArchiveBrowser.js
@@ -10,6 +10,8 @@ import { resolveRef, rootSections } from './metainfo'
 import { Title, metainfoAdaptorFactory, DefinitionLabel } from './MetainfoBrowser'
 import { Matrix, Number } from './visualizations'
 import Structure from '../visualization/Structure'
+import BandStructure from '../visualization/BandStructure'
+import DOS from '../visualization/DOS'
 import { StructureViewer } from '@lauri-codes/materia'
 import Markdown from '../Markdown'
 import { convert } from '../../utils'
@@ -305,6 +307,25 @@ QuantityValue.propTypes = ({
  * title.
  */
 function Overview({section, def}) {
+  // Styles
+  const useStyles = makeStyles(
+    {
+      bands: {
+        width: '30rem',
+        height: '30rem'
+      },
+      dos: {
+        width: '20',
+        height: '20'
+      },
+      bz: {
+        width: '20',
+        height: '20'
+      }
+    }
+  )
+  const style = useStyles()
+
   // Structure visualization for section_system
   if (def.name === 'section_system') {
     let url = window.location.href
@@ -338,7 +359,25 @@ function Overview({section, def}) {
     visualizedSystem.sectionPath = sectionPath
     visualizedSystem.index = index
 
-    return <Structure viewer={viewer} system={system} positionsOnly={positionsOnly}></Structure>
+    return <Structure
+      viewer={viewer}
+      system={system}
+      positionsOnly={positionsOnly}
+    ></Structure>
+  // Band structure plot for section_k_band or section_k_band_normalized
+  } else if (def.name === 'section_k_band' || def.name === 'section_k_band_normalized') {
+    return <BandStructure
+      className={style.bands}
+      data={section}
+      aspectRatio={1}
+    ></BandStructure>
+  // DOS plot for section_dos
+  } else if (def.name === 'section_dos') {
+    return <DOS
+      className={style.dos}
+      data={section}
+      aspectRatio={1 / 2}
+    ></DOS>
   }
   return null
 }
diff --git a/gui/src/components/material/ElectronicStructureOverview.js b/gui/src/components/material/ElectronicStructureOverview.js
new file mode 100644
index 0000000000000000000000000000000000000000..790779ce55684f8668aa5ee6895c8c71e74469b6
--- /dev/null
+++ b/gui/src/components/material/ElectronicStructureOverview.js
@@ -0,0 +1,139 @@
+import React, { useEffect, useState, useCallback } from 'react'
+import PropTypes from 'prop-types'
+import {
+  Box,
+  Card,
+  CardHeader,
+  CardContent
+} from '@material-ui/core'
+import DOS from '../visualization/DOS'
+import BandStructure from '../visualization/BandStructure'
+import BrillouinZone from '../visualization/BrillouinZone'
+import { makeStyles } from '@material-ui/core/styles'
+import { withApi } from '../api'
+
+function ElectronicStructureOverview({data, range, className, classes, api, raiseError}) {
+  const [dos, setDos] = useState()
+  const [dosLayout, setDosLayout] = useState({yaxis: {range: range}})
+  const [bs, setBs] = useState()
+  const [bsLayout, setBsLayout] = useState({yaxis: {range: range}})
+
+  // Styles
+  const useStyles = makeStyles((theme) => {
+    return {
+      row: {
+        display: 'flex',
+        flexDirection: 'row',
+        width: '100%'
+      },
+      bz: {
+        flex: '1 1 25%'
+      },
+      bands: {
+        flex: '1 1 50%'
+      },
+      dos: {
+        flex: '1 1 25%'
+      }
+    }
+  })
+  const style = useStyles(classes)
+
+  // Load the data parallelly from API on first render
+  useEffect(() => {
+    if (data === undefined) {
+      return
+    }
+    // Check what data is available and request each in parallel
+    let representatives = data.calculations.representatives
+    let promises = []
+    let requestedProperties = ['electronic_dos', 'electronic_band_structure']
+    let availableProperties = []
+    for (let property of requestedProperties) {
+      if (representatives.hasOwnProperty(property)) {
+        promises.push(
+          api.encyclopediaCalculation(
+            data.basic.material_id,
+            representatives[property],
+            {'properties': [property]}
+          )
+        )
+        availableProperties.push(property)
+      }
+    }
+    Promise.allSettled(promises).then((results) => {
+      for (let i = 0; i < availableProperties.length; ++i) {
+        let property = availableProperties[i]
+        let result = results[i].value[property]
+        if (property === 'electronic_dos') {
+          setDos(result)
+        }
+        if (property === 'electronic_band_structure') {
+          setBs(result)
+        }
+      }
+    }).catch(error => {
+      console.log(error)
+      if (error.name === 'DoesNotExist') {
+        raiseError(error)
+      }
+    })
+  }, [data, api, raiseError])
+
+  // Synchronize panning between BS/DOS plots
+  const handleBSRelayouting = useCallback((event) => {
+    let update = {
+      yaxis: {
+        range: [event['yaxis.range[0]'], event['yaxis.range[1]']]
+      }
+    }
+    setDosLayout(update)
+  }, [])
+  const handleDOSRelayouting = useCallback((event) => {
+    let update = {
+      yaxis: {
+        range: [event['yaxis.range[0]'], event['yaxis.range[1]']]
+      }
+    }
+    setBsLayout(update)
+  }, [])
+
+  return (
+    <Card>
+      <CardHeader title="Electronic structure" />
+      <CardContent>
+        <Box className={style.row}>
+          <BrillouinZone data={bs} className={style.bz} aspectRatio={1 / 2}></BrillouinZone>
+          <BandStructure
+            data={bs}
+            layout={bsLayout}
+            className={style.bands}
+            aspectRatio={1}
+            onRelayouting={handleBSRelayouting}
+          ></BandStructure>
+          <DOS
+            data={dos}
+            layout={dosLayout}
+            className={style.dos}
+            aspectRatio={1 / 2}
+            onRelayouting={handleDOSRelayouting}
+          ></DOS>
+        </Box>
+      </CardContent>
+    </Card>
+  )
+}
+
+ElectronicStructureOverview.propTypes = {
+  data: PropTypes.object,
+  range: PropTypes.array,
+  className: PropTypes.string,
+  classes: PropTypes.object,
+  api: PropTypes.object,
+  raiseError: PropTypes.func
+}
+ElectronicStructureOverview.defaultProps = {
+  range: [-10, 20]
+}
+
+export default withApi(false, true)(ElectronicStructureOverview)
diff --git a/gui/src/components/material/MaterialPage.js b/gui/src/components/material/MaterialPage.js
new file mode 100644
index 0000000000000000000000000000000000000000..81d427d7951b8d778e2565da9e76ffa596f53175
--- /dev/null
+++ b/gui/src/components/material/MaterialPage.js
@@ -0,0 +1,61 @@
+import React, { useEffect, useState } from 'react'
+import PropTypes from 'prop-types'
+import { Box } from '@material-ui/core'
+import ElectronicStructureOverview from './ElectronicStructureOverview'
+import { useRouteMatch, Route } from 'react-router-dom'
+import { withApi } from '../api'
+
+const MaterialPageContent = withApi(false, true)(({fixed, api, materialId, raiseError}) => {
+  const props = fixed ? {maxWidth: 1200} : {}
+  const [data, setData] = useState()
+
+  // Load the data parallelly from API on first render
+  useEffect(() => {
+    Promise.all([
+      api.encyclopediaBasic(materialId),
+      api.encyclopediaCalculations(materialId)
+    ]).then((results) => {
+      setData({
+        basic: results[0],
+        calculations: results[1]
+      })
+    }).catch(error => {
+      if (error.name === 'DoesNotExist') {
+        raiseError(error)
+      }
+    })
+  }, [api, materialId, raiseError])
+
+  return <Box padding={3} margin="auto" {...props}>
+    <ElectronicStructureOverview data={data}></ElectronicStructureOverview>
+  </Box>
+})
+MaterialPageContent.propTypes = ({
+  materialId: PropTypes.string,
+  api: PropTypes.func,
+  raiseError: PropTypes.func,
+  fixed: PropTypes.bool
+})
+function MaterialPage() {
+  const { path } = useRouteMatch()
+
+  return (
+    <Route
+      path={`${path}/:materialId?/:tab?`}
+      render={({match: {params: {materialId, tab = 'overview'}}}) => {
+        if (materialId) {
+          return (
+            <React.Fragment>
+              <MaterialPageContent fixed={true} materialId={materialId}>
+              </MaterialPageContent>
+            </React.Fragment>
+          )
+        } else {
+          return ''
+        }
+      }}
+    />
+  )
+}
+
+export default MaterialPage
diff --git a/gui/src/components/search/MaterialsList.js b/gui/src/components/search/MaterialsList.js
index 0160e662d1c9992dfcb9835f77df597e648b1e7e..94a2409c504f9bbacf2f902cfdd797f2adcad30d 100644
--- a/gui/src/components/search/MaterialsList.js
+++ b/gui/src/components/search/MaterialsList.js
@@ -75,6 +75,11 @@ export default function MaterialsList(props) {
     paginationText = `1-${results.length.toLocaleString()} of ${(total || 0).toLocaleString()}`
   }
 
+  /*   const handleViewMaterial = useCallback((event, materialId) => {
+    event.stopPropagation()
+    history.push(`/material/${materialId}/overview`)
+  }, [history]) */
+
   const pagination = <TableCell colSpan={1000} classes={{root: classes.scrollCell}}>
     <Toolbar className={classes.scrollBar}>
       <span className={classes.scrollSpacer}>&nbsp;</span>
@@ -92,6 +97,9 @@ export default function MaterialsList(props) {
     <IconButton href={`${appBase}/encyclopedia/#/material/${entry.encyclopedia.material.material_id}`}>
       <DetailsIcon />
     </IconButton>
+    {/*     <IconButton onClick={event => handleViewMaterial(event, entry.encyclopedia.material.material_id)}>
+      <DetailsIcon />
+    </IconButton> */}
   </Tooltip>
 
   return <DataTable
diff --git a/gui/src/components/visualization/BandStructure.js b/gui/src/components/visualization/BandStructure.js
new file mode 100644
index 0000000000000000000000000000000000000000..4edf22e2ebc5679367fc6717b178efef28e81f07
--- /dev/null
+++ b/gui/src/components/visualization/BandStructure.js
@@ -0,0 +1,244 @@
+import React, {useState, useEffect, useMemo} from 'react'
+import PropTypes from 'prop-types'
+import { makeStyles, useTheme } from '@material-ui/core/styles'
+import clsx from 'clsx'
+import {
+  Box
+} from '@material-ui/core'
+import Plot from '../visualization/Plot'
+import { convert, distance, mergeObjects } from '../../utils'
+
+export default function BandStructure({data, layout, aspectRatio, className, classes, onRelayout, onAfterPlot, onRedraw, onRelayouting}) {
+  const [finalData, setFinalData] = useState(undefined)
+  const [pathSegments, setPathSegments] = useState(undefined)
+
+  // Styles
+  const useStyles = makeStyles(
+    {
+      root: {
+      }
+    }
+  )
+  const style = useStyles(classes)
+  const theme = useTheme()
+
+  // Determine the final plotted data based on the received data. Will work with
+  // normalized and unnormalized data.
+  useEffect(() => {
+    if (data === undefined) {
+      return
+    }
+
+    // Determine if data is normalized
+    const norm = data.section_k_band_segment_normalized === undefined ? '' : '_normalized'
+    const segmentName = 'section_k_band_segment' + norm
+    const energyName = 'band_energies' + norm
+    const kpointName = 'band_k_points' + norm
+
+    let plotData = []
+    let nChannels = data[segmentName][0][energyName].length
+    let nBands = data[segmentName][0][energyName][0][0].length
+
+    // Calculate distances if missing
+    let tempSegments = []
+    if (data[segmentName][0].k_path_distances === undefined) {
+      let length = 0
+      for (let segment of data[segmentName]) {
+        const k_path_distances = []
+        const nKPoints = segment[energyName][0].length
+        let start = segment[kpointName][0]
+        let end = segment[kpointName].slice(-1)[0]
+        let segmentLength = distance(start, end)
+        for (let iKPoint = 0; iKPoint < nKPoints; ++iKPoint) {
+          const kPoint = segment[kpointName][iKPoint]
+          const dist = distance(start, kPoint)
+          k_path_distances.push(length + dist)
+        }
+        length += segmentLength
+        segment.k_path_distances = k_path_distances
+        tempSegments.push(k_path_distances)
+      }
+    } else {
+      for (let segment of data[segmentName]) {
+        tempSegments.push(segment.k_path_distances)
+      }
+    }
+    setPathSegments(tempSegments)
+
+    // Path
+    let path = []
+    for (let segment of data[segmentName]) {
+      path = path.concat(segment.k_path_distances)
+      tempSegments.push(segment.k_path_distances)
+    }
+
+    // Second spin channel
+    if (nChannels === 2) {
+      let bands = []
+      for (let iBand = 0; iBand < nBands; ++iBand) {
+        bands.push([])
+      }
+      for (let segment of data[segmentName]) {
+        for (let iBand = 0; iBand < nBands; ++iBand) {
+          let nKPoints = segment[energyName][0].length
+          for (let iKPoint = 0; iKPoint < nKPoints; ++iKPoint) {
+            bands[iBand].push(segment[energyName][0][iKPoint][iBand])
+          }
+        }
+      }
+
+      // Create plot data entry for each band
+      for (let band of bands) {
+        band = convert(band, 'joule', 'eV')
+        plotData.push(
+          {
+            x: path,
+            y: band,
+            type: 'scatter',
+            mode: 'lines',
+            line: {
+              color: theme.palette.secondary.main,
+              width: 2
+            }
+          }
+        )
+      }
+    }
+
+    // First spin channel
+    let bands = []
+    for (let iBand = 0; iBand < nBands; ++iBand) {
+      bands.push([])
+    }
+    for (let segment of data[segmentName]) {
+      for (let iBand = 0; iBand < nBands; ++iBand) {
+        let nKPoints = segment[energyName][0].length
+        for (let iKPoint = 0; iKPoint < nKPoints; ++iKPoint) {
+          bands[iBand].push(segment[energyName][0][iKPoint][iBand])
+        }
+      }
+      path = path.concat(segment.k_path_distances)
+    }
+
+    // Create plot data entry for each band
+    for (let band of bands) {
+      band = convert(band, 'joule', 'eV')
+      plotData.push(
+        {
+          x: path,
+          y: band,
+          type: 'scatter',
+          mode: 'lines',
+          line: {
+            color: theme.palette.primary.main,
+            width: 2
+          }
+        }
+      )
+    }
+
+    setFinalData(plotData)
+  }, [data, theme.palette.primary.main, theme.palette.secondary.main])
+
+  // Merge custom layout with default layout
+  const tmpLayout = useMemo(() => {
+    let defaultLayout = {
+      xaxis: {
+        tickfont: {
+          size: 14
+        }
+      },
+      yaxis: {
+        title: {
+          text: 'Energy (eV)'
+        }
+      }
+    }
+    mergeObjects(layout, defaultLayout)
+    return defaultLayout
+  }, [layout])
+
+  // Compute layout that depends on data.
+  const computedLayout = useMemo(() => {
+    if (data === undefined || pathSegments === undefined) {
+      return {}
+    }
+    // Set new layout that contains the segment labels
+    const norm = data.section_k_band_segment_normalized === undefined ? '' : '_normalized'
+    const segmentName = 'section_k_band_segment' + norm
+    const labelName = 'band_segm_labels' + norm
+    let labels = []
+    let labelKPoints = []
+    for (let iSegment = 0; iSegment < data[segmentName].length; ++iSegment) {
+      let segment = data[segmentName][iSegment]
+      if (iSegment === 0) {
+        // If label is not defined, use empty string
+        const startLabel = segment[labelName] ? segment[labelName][0] : ''
+        labels.push(startLabel)
+        labelKPoints.push(pathSegments[iSegment][0])
+      }
+      const endLabel = segment[labelName] ? segment[labelName][1] : ''
+      labels.push(endLabel)
+      labelKPoints.push(pathSegments[iSegment].slice(-1)[0])
+    }
+    let shapes = []
+    for (let iShape = 1; iShape < labelKPoints.length - 1; ++iShape) {
+      let labelKPoint = labelKPoints[iShape]
+      shapes.push({
+        type: 'line',
+        x0: labelKPoint,
+        y0: 0,
+        x1: labelKPoint,
+        y1: 1,
+        yref: 'paper',
+        line: {
+          color: '#999',
+          width: 1,
+          dash: '10px,10px'
+        }
+      })
+    }
+    let ticks = {
+      shapes: shapes,
+      xaxis: {
+        tickmode: 'array',
+        tickvals: labelKPoints,
+        ticktext: labels
+      }
+    }
+    return ticks
+  }, [data, pathSegments])
+
+  // Merge the given layout and layout computed from data
+  const finalLayout = useMemo(() => {
+    return mergeObjects(computedLayout, tmpLayout, 'shallow')
+  }, [computedLayout, tmpLayout])
+
+  return (
+    <Box className={clsx(style.root, className)}>
+      <Plot
+        data={finalData}
+        layout={finalLayout}
+        aspectRatio={aspectRatio}
+        floatTitle={'Band structure'}
+        onRelayout={onRelayout}
+        onAfterPlot={onAfterPlot}
+        onRedraw={onRedraw}
+        onRelayouting={onRelayouting}
+      >
+      </Plot>
+    </Box>
+  )
+}
+
+BandStructure.propTypes = {
+  data: PropTypes.object, // section_band_structure or section_band_structure_normalized
+  layout: PropTypes.object,
+  aspectRatio: PropTypes.number,
+  classes: PropTypes.object,
+  className: PropTypes.string,
+  onAfterPlot: PropTypes.func,
+  onRedraw: PropTypes.func,
+  onRelayout: PropTypes.func,
+  onRelayouting: PropTypes.func
+}
diff --git a/gui/src/components/visualization/BrillouinZone.js b/gui/src/components/visualization/BrillouinZone.js
new file mode 100644
index 0000000000000000000000000000000000000000..b0e39e65de355a0614ee1eceaad712bde0a1bfb3
--- /dev/null
+++ b/gui/src/components/visualization/BrillouinZone.js
@@ -0,0 +1,342 @@
+import React, { useState, useEffect, useRef, useCallback } from 'react'
+import clsx from 'clsx'
+import PropTypes from 'prop-types'
+import { makeStyles } from '@material-ui/core/styles'
+import {
+  Box,
+  Checkbox,
+  Menu,
+  MenuItem,
+  IconButton,
+  Tooltip,
+  Typography,
+  FormControlLabel
+} from '@material-ui/core'
+import {
+  MoreVert,
+  Fullscreen,
+  FullscreenExit,
+  CameraAlt,
+  Replay
+} from '@material-ui/icons'
+import { BrillouinZoneViewer } from '@lauri-codes/materia'
+import Floatable from './Floatable'
+
+export default function BrillouinZone({className, classes, options, viewer, system, positionsOnly, sizeLimit, captureName, aspectRatio}) {
+  // States
+  const [anchorEl, setAnchorEl] = React.useState(null)
+  const [fullscreen, setFullscreen] = useState(false)
+  const [showBonds, setShowBonds] = useState(true)
+  const [showLatticeConstants, setShowLatticeConstants] = useState(true)
+  const [showCell, setShowCell] = useState(true)
+  const [error, setError] = useState(null)
+
+  // Variables
+  const open = Boolean(anchorEl)
+  const refViewer = useRef(null)
+  const refCanvas = useRef(null)
+
+  // Styles
+  const useStyles = makeStyles((theme) => {
+    return {
+      root: {
+      },
+      container: {
+        display: 'flex',
+        width: '100%',
+        height: '100%',
+        flexDirection: 'column',
+        backgroundColor: 'white'
+      },
+      header: {
+        display: 'flex',
+        flexDirection: 'row',
+        zIndex: 1
+      },
+      spacer: {
+        flex: 1
+      },
+      viewerCanvas: {
+        flex: 1,
+        zIndex: 0,
+        minHeight: 0, // added min-height: 0 to allow the item to shrink to fit inside the container.
+        marginBottom: theme.spacing(2),
+        display: error === null ? 'block' : 'none'
+      },
+      errorContainer: {
+        flex: 1,
+        zIndex: 0,
+        minHeight: 0, // added min-height: 0 to allow the item to shrink to fit inside the container.
+        marginBottom: theme.spacing(2),
+        alignItems: 'center',
+        justifyContent: 'center',
+        display: error === null ? 'none' : 'flex'
+      },
+      errorMessage: {
+        flex: '0 0 70%',
+        color: '#aaa',
+        textAlign: 'center'
+      },
+      iconButton: {
+        backgroundColor: 'white'
+      }
+    }
+  })
+  let style = useStyles(classes)
+
+  // In order to properly detect changes in a reference, a reference callback is
+  // used. This is the recommended way to monitor reference changes as a simple
+  // useRef is not guaranteed to update:
+  // https://reactjs.org/docs/hooks-faq.html#how-can-i-measure-a-dom-node
+  const measuredRef = useCallback(node => {
+    refCanvas.current = node
+    if (node === null) {
+      return
+    }
+    if (refViewer.current === null) {
+      return
+    }
+    refViewer.current.changeHostElement(node, true, true)
+  }, [])
+
+  // Run only on first render to initialize the viewer. See the viewer
+  // documentation for details on the meaning of different options:
+  // https://lauri-codes.github.io/materia/viewers/structureviewer
+  useEffect(() => {
+    let viewerOptions
+    if (options === undefined) {
+      viewerOptions = {
+        view: {
+          autoResize: true,
+          autoFit: true,
+          fitMargin: 0.5
+        },
+        bonds: {
+          enabled: true
+        },
+        latticeConstants: {
+          size: 0.7,
+          font: 'Titillium Web,sans-serif',
+          a: {color: '#f44336'},
+          b: {color: '#4caf50'},
+          c: {color: '#5c6bc0'}
+        },
+        controls: {
+          enableZoom: true,
+          enablePan: true,
+          enableRotate: true
+        },
+        renderer: {
+          backgroundColor: ['#ffffff', 1],
+          shadows: {
+            enabled: false
+          }
+        }
+      }
+    } else {
+      viewerOptions = options
+    }
+
+    if (viewer === undefined) {
+      refViewer.current = new BrillouinZoneViewer(undefined, viewerOptions)
+    } else {
+      refViewer.current = viewer
+      refViewer.current.setOptions(viewerOptions, false, false)
+    }
+    if (refCanvas.current !== null) {
+      refViewer.current.changeHostElement(refCanvas.current, false, false)
+    }
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [])
+
+  // Called only on first render to load the given structure.
+  useEffect(() => {
+    if (system === undefined) {
+      return
+    }
+
+    if (positionsOnly) {
+      refViewer.current.setPositions(system.positions)
+      return
+    }
+
+    let nAtoms = system.species.length
+    if (nAtoms >= sizeLimit) {
+      setError('Visualization is disabled due to large system size.')
+      return
+    }
+
+    // Systems with cell are centered on the cell center and orientation is defined
+    // by the cell vectors.
+    let cell = system.cell
+    if (cell !== undefined) {
+      refViewer.current.setOptions({layout: {
+        viewCenter: 'COC',
+        viewRotation: {
+          align: {
+            top: 'c',
+            right: 'b'
+          },
+          rotations: [
+            [0, 1, 0, 60],
+            [1, 0, 0, 30]
+          ]
+        }
+      }})
+    // Systems without cell are centered on the center of positions
+    } else {
+      refViewer.current.setOptions({layout: {
+        viewCenter: 'COP',
+        viewRotation: {
+          rotations: [
+            [0, 1, 0, 60],
+            [1, 0, 0, 30]
+          ]
+        }
+      }})
+    }
+    refViewer.current.load(system)
+    refViewer.current.fitToCanvas()
+    refViewer.current.saveReset()
+    refViewer.current.reset()
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [])
+
+  // Viewer settings
+  useEffect(() => {
+    refViewer.current.setOptions({bonds: {enabled: showBonds}})
+  }, [showBonds])
+
+  useEffect(() => {
+    refViewer.current.setOptions({latticeConstants: {enabled: showLatticeConstants}})
+  }, [showLatticeConstants])
+
+  useEffect(() => {
+    refViewer.current.setOptions({cell: {enabled: showCell}})
+  }, [showCell])
+
+  // Memoized callbacks
+  const openMenu = useCallback((event) => {
+    setAnchorEl(event.currentTarget)
+  }, [])
+
+  const closeMenu = useCallback(() => {
+    setAnchorEl(null)
+  }, [])
+
+  const toggleFullscreen = useCallback(() => {
+    setFullscreen(!fullscreen)
+  }, [fullscreen])
+
+  const takeScreencapture = useCallback(() => {
+    refViewer.current.takeScreenShot(captureName)
+  }, [captureName])
+
+  const handleReset = useCallback(() => {
+    refViewer.current.reset()
+    refViewer.current.fitToCanvas()
+    refViewer.current.render()
+  }, [])
+
+  const content = <Box className={style.container}>
+    <div className={style.header}>
+      {fullscreen && <Typography variant="h6">Structure</Typography>}
+      <div className={style.spacer}></div>
+      <Tooltip title="Reset view">
+        <IconButton className={style.iconButton} onClick={handleReset} disabled={error}>
+          <Replay />
+        </IconButton>
+      </Tooltip>
+      <Tooltip
+        title="Toggle fullscreen">
+        <IconButton className={style.iconButton} onClick={toggleFullscreen} disabled={error}>
+          {fullscreen ? <FullscreenExit /> : <Fullscreen />}
+        </IconButton>
+      </Tooltip>
+      <Tooltip title="Capture image">
+        <IconButton className={style.iconButton} onClick={takeScreencapture} disabled={error}>
+          <CameraAlt />
+        </IconButton>
+      </Tooltip>
+      <Tooltip title="Options">
+        <IconButton className={style.iconButton} onClick={openMenu} disabled={error}>
+          <MoreVert />
+        </IconButton>
+      </Tooltip>
+      <Menu
+        id='settings-menu'
+        anchorEl={anchorEl}
+        getContentAnchorEl={null}
+        anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
+        transformOrigin={{ vertical: 'top', horizontal: 'right' }}
+        keepMounted
+        open={open}
+        onClose={closeMenu}
+      >
+        <MenuItem key='show-bonds'>
+          <FormControlLabel
+            control={
+              <Checkbox
+                checked={showBonds}
+                onChange={(event) => { setShowBonds(!showBonds) }}
+                color='primary'
+              />
+            }
+            label='Show bonds'
+          />
+        </MenuItem>
+        <MenuItem key='show-axis'>
+          <FormControlLabel
+            control={
+              <Checkbox
+                checked={showLatticeConstants}
+                onChange={(event) => { setShowLatticeConstants(!showLatticeConstants) }}
+                color='primary'
+              />
+            }
+            label='Show lattice constants'
+          />
+        </MenuItem>
+        <MenuItem key='show-cell'>
+          <FormControlLabel
+            control={
+              <Checkbox
+                checked={showCell}
+                onChange={(event) => { setShowCell(!showCell) }}
+                color='primary'
+              />
+            }
+            label='Show simulation cell'
+          />
+        </MenuItem>
+      </Menu>
+    </div>
+    <div className={style.viewerCanvas} ref={measuredRef}></div>
+    <div className={style.errorContainer}><div className={style.errorMessage}>{error}</div></div>
+  </Box>
+
+  return (
+    <Box className={clsx(style.root, className)} >
+      <Floatable float={fullscreen} onFloat={toggleFullscreen} aspectRatio={aspectRatio}>
+        {content}
+      </Floatable>
+    </Box>
+  )
+}
+
+BrillouinZone.propTypes = {
+  viewer: PropTypes.object, // Optional shared viewer instance.
+  system: PropTypes.object, // The system to display as section_system
+  options: PropTypes.object, // Viewer options
+  captureName: PropTypes.string, // Name of the file that the user can download
+  aspectRatio: PropTypes.number, // Fixed aspect ratio for the viewer canvas
+  sizeLimit: PropTypes.number, // Maximum number of atoms to attempt to display
+  positionsOnly: PropTypes.bool, // Whether to update only positions. This is much faster than loading the entire structure.
+  classes: PropTypes.object,
+  className: PropTypes.string
+}
+BrillouinZone.defaultProps = {
+  aspectRatio: 4 / 3,
+  captureName: 'structure',
+  sizeLimit: 300
+}
diff --git a/gui/src/components/visualization/DOS.js b/gui/src/components/visualization/DOS.js
new file mode 100644
index 0000000000000000000000000000000000000000..ae8a2aaaeb8d4f069eeb902811f7d94e1595313a
--- /dev/null
+++ b/gui/src/components/visualization/DOS.js
@@ -0,0 +1,127 @@
+import React, {useState, useEffect, useMemo} from 'react'
+import PropTypes from 'prop-types'
+import { makeStyles, useTheme } from '@material-ui/core/styles'
+import clsx from 'clsx'
+import {
+  Box
+} from '@material-ui/core'
+import Plot from '../visualization/Plot'
+import { convert, mergeObjects } from '../../utils'
+
+export default function DOS({data, layout, aspectRatio, className, classes, onRelayout, onAfterPlot, onRedraw, onRelayouting}) {
+  const [finalData, setFinalData] = useState(undefined)
+
+  // Merge custom layout with default layout
+  const tmpLayout = useMemo(() => {
+    let defaultLayout = {
+      yaxis: {
+        title: {
+          text: 'Energy (eV)'
+        }
+      }
+    }
+    return mergeObjects(layout, defaultLayout)
+  }, [layout])
+
+  // Styles
+  const useStyles = makeStyles(
+    {
+      root: {
+      }
+    }
+  )
+  const style = useStyles(classes)
+  const theme = useTheme()
+
+  // The plotted data is loaded only after the first render as a side effect to
+  // avoid freezing the UI
+  useEffect(() => {
+    if (data === undefined) {
+      return
+    }
+    const norm = data.dos_energies_normalized === undefined ? '' : '_normalized'
+    const energyName = 'dos_energies' + norm
+    const valueName = 'dos_values' + norm
+    const plotData = []
+    if (data !== undefined) {
+      let nChannels = data[valueName].length
+      let energies = convert(data[energyName], 'joule', 'eV')
+      if (nChannels === 2) {
+        plotData.push(
+          {
+            x: data[valueName][1],
+            y: energies,
+            type: 'scatter',
+            mode: 'lines',
+            line: {
+              color: theme.palette.secondary.main,
+              width: 2
+            }
+          }
+        )
+      }
+      plotData.push(
+        {
+          x: data[valueName][0],
+          y: energies,
+          type: 'scatter',
+          mode: 'lines',
+          line: {
+            color: theme.palette.primary.main,
+            width: 2
+          }
+        }
+      )
+    }
+    setFinalData(plotData)
+  }, [data, theme.palette.primary.main, theme.palette.secondary.main])
+
+  // Compute layout that depends on data.
+  const computedLayout = useMemo(() => {
+    if (data === undefined) {
+      return {}
+    }
+    const norm = data.dos_energies_normalized !== undefined
+    let defaultLayout = {
+      xaxis: {
+        title: {
+          text: norm ? 'states/eV/m<sup>3</sup>/atom' : 'states/eV/cell'
+        }
+      }
+    }
+    return defaultLayout
+  }, [data])
+
+  // Merge the given layout and layout computed from data
+  const finalLayout = useMemo(() => {
+    return mergeObjects(computedLayout, tmpLayout)
+  }, [computedLayout, tmpLayout])
+
+  return (
+    <Box className={clsx(style.root, className)}>
+      <Plot
+        data={finalData}
+        layout={finalLayout}
+        aspectRatio={aspectRatio}
+        floatTitle="Density of states"
+        onRelayout={onRelayout}
+        onAfterPlot={onAfterPlot}
+        onRedraw={onRedraw}
+        onRelayouting={onRelayouting}
+      >
+      </Plot>
+    </Box>
+  )
+}
+
+DOS.propTypes = {
+  data: PropTypes.object, // section_dos
+  layout: PropTypes.object,
+  aspectRatio: PropTypes.number,
+  classes: PropTypes.object,
+  className: PropTypes.string,
+  onAfterPlot: PropTypes.func,
+  onRedraw: PropTypes.func,
+  onRelayout: PropTypes.func,
+  onRelayouting: PropTypes.func
+}
diff --git a/gui/src/components/visualization/Floatable.js b/gui/src/components/visualization/Floatable.js
new file mode 100644
index 0000000000000000000000000000000000000000..3e04403dd1e9084cce5da4730ff8cef22bfb5a11
--- /dev/null
+++ b/gui/src/components/visualization/Floatable.js
@@ -0,0 +1,140 @@
+import React from 'react'
+import { makeStyles } from '@material-ui/core/styles'
+import {
+  Box,
+  Button,
+  Dialog,
+  DialogContent,
+  DialogActions
+} from '@material-ui/core'
+import PropTypes from 'prop-types'
+import clsx from 'clsx'
+
+/**
+ * Component that wraps it's children in a container that can be 'floated',
+ * i.e. displayed on an html element that is positioned relative to the
+ * viewport and is above all other elements.
+ */
+export default function Floatable({className, classes, float, children, aspectRatio, onFloat}) {
+  // Styles
+  const useStyles = makeStyles((theme) => {
+    // Calculate the fullscreen size
+    const actionsHeight = 52.5 // Size of the actions element that is shown when floating
+    const padding = 20 // Padding arount the floating object
+    const maxWidth = 1280 // Maximum width for the floating window
+    const margin = 0.1 * (window.innerHeight) + actionsHeight
+    const windowHeight = window.innerHeight - margin
+    const windowWidth = Math.min(window.innerWidth, maxWidth) - margin
+    const windowRatio = windowWidth / windowHeight
+    let width
+    let height
+    if (windowRatio > aspectRatio) {
+      width = (windowHeight) * aspectRatio + 2 * padding + 'px'
+      height = windowHeight + actionsHeight + 2 * padding
+    } else {
+      width = windowWidth + 2 * padding
+      height = (windowWidth) / aspectRatio + actionsHeight + 2 * padding + 'px'
+    }
+
+    return {
+      root: {
+      },
+      containerOuter: {
+        width: '100%',
+        height: 0,
+        paddingBottom: `${100 / aspectRatio}%`, // CSS hack for fixed aspect ratio
+        position: 'relative',
+        boxSizing: 'border-box'
+      },
+      containerInner: {
+        position: 'absolute',
+        top: 0,
+        right: 0,
+        bottom: 0,
+        left: 0,
+        display: 'flex',
+        flexDirection: 'column',
+        boxSizing: 'border-box'
+      },
+      dialogContent: {
+        boxSizing: 'border-box',
+        padding: padding
+      },
+      'dialogContent:first-child': {
+        paddingTop: `${padding} !important`
+      },
+      dialogActions: {
+        height: actionsHeight,
+        boxSizing: 'border-box'
+      },
+      dialogRoot: {
+        width: width,
+        height: height,
+        margin: 0,
+        padding: 0,
+        maxWidth: 'none',
+        maxHeight: 'none',
+        boxSizing: 'border-box'
+      }
+    }
+  })
+  const style = useStyles(classes)
+
+  return (
+    <Box className={clsx(style.root, className)}>
+      <Box className={style.containerOuter}>
+        <Box className={style.containerInner}>
+          {float ? '' : children}
+        </Box>
+      </Box>
+      <Dialog fullWidth={false} maxWidth={false} open={float}
+        classes={{paper: style.dialogRoot}}
+      >
+        <DialogContent className={[style.dialogContent, style['dialogContent:first-child']].join('_')}>
+          <Box className={style.containerOuter}>
+            <Box className={style.containerInner}>
+              {float ? children : ''}
+            </Box>
+          </Box>
+        </DialogContent>
+        <DialogActions className={style.dialogActions}>
+          <Button onClick={() => onFloat(float)}>
+            Close
+          </Button>
+        </DialogActions>
+      </Dialog>
+    </Box>
+  )
+}
+
+Floatable.propTypes = {
+  float: PropTypes.bool.isRequired,
+  /**
+   * Fixed aspect ratio that is enforced for this component.
+   */
+  aspectRatio: PropTypes.number.isRequired,
+  /**
+   * Callback that is called whenever this component requests a change in the
+   * float property. The callback accepts one parameter: 'float' that is a
+   * boolean indicating the current float status.
+   */
+  onFloat: PropTypes.any,
+  /**
+   * Child components
+   */
+  children: PropTypes.any,
+  /**
+   * CSS class for the root element.
+   */
+  className: PropTypes.string,
+  /**
+   * CSS classes for this component.
+   */
+  classes: PropTypes.object
+  /**
+   * Controls the float status.
+   */
+}
+Floatable.defaultProps = {
+  float: false
+}
diff --git a/gui/src/components/visualization/Plot.js b/gui/src/components/visualization/Plot.js
new file mode 100644
index 0000000000000000000000000000000000000000..1800e34f57054697fd6dc3dce5c97f9ddb638038
--- /dev/null
+++ b/gui/src/components/visualization/Plot.js
@@ -0,0 +1,275 @@
+import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react'
+import PropTypes from 'prop-types'
+import { makeStyles } from '@material-ui/core/styles'
+import { cloneDeep } from 'lodash'
+
+import {
+  IconButton,
+  Tooltip,
+  Typography
+} from '@material-ui/core'
+import {
+  MoreVert,
+  Fullscreen,
+  FullscreenExit,
+  CameraAlt,
+  Replay
+} from '@material-ui/icons'
+import Floatable from './Floatable'
+import Plotly from 'plotly.js-cartesian-dist-min'
+import clsx from 'clsx'
+import { mergeObjects } from '../../utils'
+
+export default function Plot({data, layout, config, menu, floatTitle, capture, aspectRatio, className, classes, onRelayout, onAfterPlot, onRedraw, onRelayouting}) {
+  // States
+  const [float, setFloat] = useState(false)
+  const [initialLayout, setInitialLayout] = useState()
+  const [captureSettings, setCaptureSettings] = useState()
+  const firstUpdate = useRef(true)
+  const dataInitialized = useRef(false)
+
+  React.useEffect(() => {
+    let defaultCapture = {
+      format: 'svg',
+      width: 1280,
+      height: 960 / aspectRatio,
+      filename: 'plot'
+    }
+    let settings = mergeObjects(capture, defaultCapture)
+    setCaptureSettings(settings)
+  }, [capture, aspectRatio])
+
+  // Styles
+  const useStyles = makeStyles((theme) => {
+    return {
+      header: {
+        paddingRight: 20,
+        display: 'flex',
+        flexDirection: 'row',
+        zIndex: 1
+      },
+      root: {
+      },
+      spacer: {
+        flex: 1
+      },
+      iconButton: {
+        backgroundColor: 'white'
+      }
+    }
+  })
+
+  const style = useStyles(classes)
+
+  // Set the final menu
+  const finalMenu = useMemo(() => {
+    let defaultMenu = {
+      reset: {
+        visible: true,
+        disabled: false
+      },
+      fullscreen: {
+        visible: true,
+        disabled: false
+      },
+      capture: {
+        visible: true,
+        disabled: false
+      },
+      dropdown: {
+        visible: false,
+        disabled: false,
+        items: undefined
+      }
+    }
+    return mergeObjects(menu, defaultMenu)
+  }, [menu])
+
+  // Set the final layout
+  const finalLayout = useMemo(() => {
+    let defaultLayout = {
+      dragmode: 'pan',
+      hovermode: false,
+      showlegend: false,
+      autosize: true,
+      margin: {
+        l: 60,
+        r: 20,
+        t: 20,
+        b: 50
+      },
+      xaxis: {
+        linecolor: '#333',
+        linewidth: 1,
+        mirror: true,
+        ticks: 'outside',
+        showline: true,
+        fixedrange: true,
+        title: {
+          font: {
+            family: 'Titillium Web,sans-serif',
+            size: 16,
+            color: '#333'
+          },
+          tickfont: {
+            family: 'Titillium Web,sans-serif',
+            size: 14,
+            color: '#333'
+          }
+        }
+      },
+      yaxis: {
+        linecolor: '#333',
+        linewidth: 1,
+        mirror: true,
+        ticks: 'outside',
+        showline: true,
+        title: {
+          font: {
+            family: 'Titillium Web,sans-serif',
+            size: 16,
+            color: '#333'
+          }
+        },
+        tickfont: {
+          family: 'Titillium Web,sans-serif',
+          size: 14,
+          color: '#333'
+        }
+      }
+    }
+    return mergeObjects(layout, defaultLayout)
+  }, [layout])
+
+  // Set the final config
+  const finalConfig = useMemo(() => {
+    let defaultConfig = {
+      scrollZoom: true,
+      displayModeBar: false
+    }
+    return mergeObjects(config, defaultConfig)
+  }, [config])
+
+  // Initialize the plot object on first render
+  useEffect(() => {
+    Plotly.newPlot(canvasRef.current, data, finalLayout, finalConfig)
+    if (firstUpdate.current) {
+      firstUpdate.current = false
+    }
+    if (data) {
+      setInitialLayout(cloneDeep(finalLayout))
+      dataInitialized.current = true
+    }
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [])
+
+  // In order to properly detect changes in a reference, a reference callback is
+  // used. This is the recommended way to monitor reference changes as a simple
+  // useRef is not guaranteed to update:
+  // https://reactjs.org/docs/hooks-faq.html#how-can-i-measure-a-dom-node
+  const canvasRef = useCallback(node => {
+    canvasRef.current = node
+    if (node === null) {
+      return
+    }
+    Plotly.react(canvasRef.current, data, finalLayout, finalConfig)
+  }, [data, finalLayout, finalConfig])
+
+  // Update data
+  useEffect(() => {
+    if (firstUpdate.current) {
+      return
+    }
+    Plotly.react(canvasRef.current, data, finalLayout, finalConfig)
+    if (data) {
+      if (!dataInitialized.current) {
+        setInitialLayout(cloneDeep(finalLayout))
+        dataInitialized.current = true
+      }
+    }
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [data, finalConfig])
+
+  // Update layout
+  useEffect(() => {
+    if (firstUpdate.current) {
+      return
+    }
+    Plotly.relayout(canvasRef.current, finalLayout)
+  }, [canvasRef, finalLayout])
+
+  // For resetting the view. We need to pass a deep clone of the initialLayout,
+  // as Plotly will modify the given object in-place
+  const handleReset = useCallback(() => {
+    if (initialLayout) {
+      Plotly.relayout(canvasRef.current, cloneDeep(initialLayout))
+    }
+  }, [canvasRef, initialLayout])
+
+  // Handles plot capturing
+  const handleCapture = useCallback(() => {
+    Plotly.downloadImage(canvasRef.current, captureSettings)
+  }, [canvasRef, captureSettings])
+
+  return (
+    <Floatable className={clsx(className, style.root)} float={float} onFloat={() => setFloat(!float)} aspectRatio={aspectRatio}>
+      <div className={style.header}>
+        {float && <Typography variant="h6">{floatTitle}</Typography>}
+        <div className={style.spacer}></div>
+        { finalMenu.reset.visible === true
+          ? <Tooltip title="Reset view">
+            <IconButton className={style.iconButton} onClick={handleReset} disabled={finalMenu.reset.disabled}> <Replay />
+            </IconButton>
+          </Tooltip>
+          : ''
+        }
+        { finalMenu.fullscreen.visible === true
+          ? <Tooltip
+            title="Toggle fullscreen">
+            <IconButton className={style.iconButton} onClick={() => setFloat(!float)} disabled={finalMenu.fullscreen.disabled}>
+              {float ? <FullscreenExit /> : <Fullscreen />}
+            </IconButton>
+          </Tooltip>
+          : ''
+        }
+        { finalMenu.capture.visible === true
+          ? <Tooltip title="Capture image">
+            <IconButton className={style.iconButton} onClick={handleCapture} disabled={finalMenu.capture.disabled}>
+              <CameraAlt />
+            </IconButton>
+          </Tooltip>
+          : ''
+        }
+        { finalMenu.dropdown.visible === true
+          ? <Tooltip title="Options">
+            <IconButton className={style.iconButton} onClick={() => {}} disabled={finalMenu.dropdown.disabled}>
+              <MoreVert />
+            </IconButton>
+          </Tooltip>
+          : ''
+        }
+      </div>
+      <div ref={canvasRef} style={{width: '100%', height: '100%'}}></div>
+    </Floatable>
+  )
+}
+
+Plot.propTypes = {
+  data: PropTypes.array, // Plotly.js data object
+  layout: PropTypes.object, // Plotly.js layout object
+  config: PropTypes.object, // Plotly.js config object
+  menu: PropTypes.object, // Menu settings
+  capture: PropTypes.object, // Capture settings
+  aspectRatio: PropTypes.number, // Fixed aspect ratio for the viewer canvas
+  className: PropTypes.string,
+  classes: PropTypes.string,
+  floatTitle: PropTypes.string, // The title of the plot shown in floating mode
+  onRelayout: PropTypes.func,
+  onAfterPlot: PropTypes.func,
+  onRedraw: PropTypes.func,
+  onRelayouting: PropTypes.func
+}
+Plot.defaultProps = {
+  aspectRatio: 9 / 16,
+  floatTitle: ''
+}
diff --git a/gui/src/components/visualization/Structure.js b/gui/src/components/visualization/Structure.js
index cfa8ffcb59267b551c2fcad92df5afd52327aa7f..a756e7547ab73b8eecbd5f99a0d79e29dedb0331 100644
--- a/gui/src/components/visualization/Structure.js
+++ b/gui/src/components/visualization/Structure.js
@@ -6,13 +6,9 @@ import {
   Checkbox,
   Menu,
   MenuItem,
-  Button,
   IconButton,
   Tooltip,
   Typography,
-  Dialog,
-  DialogContent,
-  DialogActions,
   FormControlLabel
 } from '@material-ui/core'
 import {
@@ -23,8 +19,9 @@ import {
   Replay
 } from '@material-ui/icons'
 import { StructureViewer } from '@lauri-codes/materia'
+import Floatable from './Floatable'
 
-export default function Structure(props) {
+export default function Structure({className, classes, system, options, viewer, captureName, aspectRatio, positionsOnly, sizeLimit}) {
   // States
   const [anchorEl, setAnchorEl] = React.useState(null)
   const [fullscreen, setFullscreen] = useState(false)
@@ -35,25 +32,12 @@ export default function Structure(props) {
 
   // Variables
   const open = Boolean(anchorEl)
-  const viewer = useRef(null)
+  const refViewer = useRef(null)
   const refCanvas = useRef(null)
 
   // Styles
   const useStyles = makeStyles((theme) => {
     return {
-      root: {
-        width: '100%',
-        height: 0,
-        paddingBottom: `${(1 / props.aspectRatio) * 100}%`, // CSS hack for fixed aspect ratio
-        position: 'relative'
-      },
-      rootInner: {
-        position: 'absolute',
-        top: 0,
-        right: 0,
-        bottom: 0,
-        left: 0
-      },
       container: {
         display: 'flex',
         width: '100%',
@@ -95,7 +79,7 @@ export default function Structure(props) {
       }
     }
   })
-  const classes = useStyles(props)
+  const style = useStyles(classes)
 
   // In order to properly detect changes in a reference, a reference callback is
   // used. This is the recommended way to monitor reference changes as a simple
@@ -106,19 +90,19 @@ export default function Structure(props) {
     if (node === null) {
       return
     }
-    if (viewer.current === null) {
+    if (refViewer.current === null) {
       return
     }
-    viewer.current.changeHostElement(node, true, true)
+    refViewer.current.changeHostElement(node, true, true)
   }, [])
 
   // Run only on first render to initialize the viewer. See the viewer
   // documentation for details on the meaning of different options:
   // https://lauri-codes.github.io/materia/viewers/structureviewer
   useEffect(() => {
-    let options
-    if (props.options === undefined) {
-      options = {
+    let viewerOptions
+    if (options === undefined) {
+      viewerOptions = {
         view: {
           autoResize: true,
           autoFit: true,
@@ -147,42 +131,42 @@ export default function Structure(props) {
         }
       }
     } else {
-      options = props.options
+      viewerOptions = options
     }
-    if (props.viewer === undefined) {
-      viewer.current = new StructureViewer(undefined, options)
+    if (viewer === undefined) {
+      refViewer.current = new StructureViewer(undefined, viewerOptions)
     } else {
-      viewer.current = props.viewer
-      viewer.current.setOptions(options, false, false)
+      refViewer.current = viewer
+      refViewer.current.setOptions(options, false, false)
     }
     if (refCanvas.current !== null) {
-      viewer.current.changeHostElement(refCanvas.current, false, false)
+      refViewer.current.changeHostElement(refCanvas.current, false, false)
     }
   // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [])
 
   // Called only on first render to load the given structure.
   useEffect(() => {
-    if (props.system === undefined) {
+    if (system === undefined) {
       return
     }
 
-    if (props.positionsOnly) {
-      viewer.current.setPositions(props.system.positions)
+    if (positionsOnly) {
+      refViewer.current.setPositions(system.positions)
       return
     }
 
-    let nAtoms = props.system.species.length
-    if (nAtoms >= props.sizeLimit) {
+    let nAtoms = system.species.length
+    if (nAtoms >= sizeLimit) {
       setError('Visualization is disabled due to large system size.')
       return
     }
 
     // Systems with cell are centered on the cell center and orientation is defined
     // by the cell vectors.
-    let cell = props.system.cell
+    let cell = system.cell
     if (cell !== undefined) {
-      viewer.current.setOptions({layout: {
+      refViewer.current.setOptions({layout: {
         viewCenter: 'COC',
         viewRotation: {
           align: {
@@ -197,7 +181,7 @@ export default function Structure(props) {
       }})
     // Systems without cell are centered on the center of positions
     } else {
-      viewer.current.setOptions({layout: {
+      refViewer.current.setOptions({layout: {
         viewCenter: 'COP',
         viewRotation: {
           rotations: [
@@ -207,24 +191,24 @@ export default function Structure(props) {
         }
       }})
     }
-    viewer.current.load(props.system)
-    viewer.current.fitToCanvas()
-    viewer.current.saveReset()
-    viewer.current.reset()
+    refViewer.current.load(system)
+    refViewer.current.fitToCanvas()
+    refViewer.current.saveReset()
+    refViewer.current.reset()
   // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [])
 
   // Viewer settings
   useEffect(() => {
-    viewer.current.setOptions({bonds: {enabled: showBonds}})
+    refViewer.current.setOptions({bonds: {enabled: showBonds}})
   }, [showBonds])
 
   useEffect(() => {
-    viewer.current.setOptions({latticeConstants: {enabled: showLatticeConstants}})
+    refViewer.current.setOptions({latticeConstants: {enabled: showLatticeConstants}})
   }, [showLatticeConstants])
 
   useEffect(() => {
-    viewer.current.setOptions({cell: {enabled: showCell}})
+    refViewer.current.setOptions({cell: {enabled: showCell}})
   }, [showCell])
 
   // Memoized callbacks
@@ -241,37 +225,37 @@ export default function Structure(props) {
   }, [fullscreen])
 
   const takeScreencapture = useCallback(() => {
-    viewer.current.takeScreenShot(props.captureName)
-  }, [props.captureName])
+    refViewer.current.takeScreenShot(captureName)
+  }, [captureName])
 
   const handleReset = useCallback(() => {
-    viewer.current.reset()
-    viewer.current.fitToCanvas()
-    viewer.current.render()
+    refViewer.current.reset()
+    refViewer.current.fitToCanvas()
+    refViewer.current.render()
   }, [])
 
-  const content = <Box className={classes.container}>
-    <div className={classes.header}>
+  const content = <Box className={style.container}>
+    <div className={style.header}>
       {fullscreen && <Typography variant="h6">Structure</Typography>}
-      <div className={classes.spacer}></div>
+      <div className={style.spacer}></div>
       <Tooltip title="Reset view">
-        <IconButton className={classes.iconButton} onClick={handleReset} disabled={error}>
+        <IconButton className={style.iconButton} onClick={handleReset} disabled={error}>
           <Replay />
         </IconButton>
       </Tooltip>
       <Tooltip
         title="Toggle fullscreen">
-        <IconButton className={classes.iconButton} onClick={toggleFullscreen} disabled={error}>
+        <IconButton className={style.iconButton} onClick={toggleFullscreen} disabled={error}>
           {fullscreen ? <FullscreenExit /> : <Fullscreen />}
         </IconButton>
       </Tooltip>
       <Tooltip title="Capture image">
-        <IconButton className={classes.iconButton} onClick={takeScreencapture} disabled={error}>
+        <IconButton className={style.iconButton} onClick={takeScreencapture} disabled={error}>
           <CameraAlt />
         </IconButton>
       </Tooltip>
       <Tooltip title="Options">
-        <IconButton className={classes.iconButton} onClick={openMenu} disabled={error}>
+        <IconButton className={style.iconButton} onClick={openMenu} disabled={error}>
           <MoreVert />
         </IconButton>
       </Tooltip>
@@ -323,34 +307,20 @@ export default function Structure(props) {
         </MenuItem>
       </Menu>
     </div>
-    <div className={classes.viewerCanvas} ref={measuredRef}></div>
-    <div className={classes.errorContainer}><div className={classes.errorMessage}>{error}</div></div>
+    <div className={style.viewerCanvas} ref={measuredRef}></div>
+    <div className={style.errorContainer}><div className={style.errorMessage}>{error}</div></div>
   </Box>
 
   return (
-    <Box className={classes.root}>
-      <Box className={classes.rootInner}>
-        {fullscreen ? '' : content}
-      </Box>
-      <Dialog maxWidth="lg" fullWidth open={fullscreen}>
-        <DialogContent>
-          <Box className={classes.root}>
-            <Box className={classes.rootInner}>
-              {fullscreen ? content : ''}
-            </Box>
-          </Box>
-        </DialogContent>
-        <DialogActions>
-          <Button onClick={() => setFullscreen(false)}>
-            Close
-          </Button>
-        </DialogActions>
-      </Dialog>
-    </Box>
+    <Floatable float={fullscreen} onFloat={toggleFullscreen} aspectRatio={aspectRatio}>
+      {content}
+    </Floatable>
   )
 }
 
 Structure.propTypes = {
+  className: PropTypes.string,
+  classes: PropTypes.object,
   viewer: PropTypes.object, // Optional shared viewer instance.
   system: PropTypes.object, // The system to display as section_system
   options: PropTypes.object, // Viewer options
diff --git a/gui/src/utils.js b/gui/src/utils.js
index 0e0212e72b305d04bc880cd73189c56e417fbc63..25c3bcb68c881b7a1f6c537e6713d87761e2cb25 100644
--- a/gui/src/utils.js
+++ b/gui/src/utils.js
@@ -1,4 +1,5 @@
 import { unit } from 'mathjs'
+import { cloneDeep, merge } from 'lodash'
 
 export const isEquivalent = (a, b) => {
   // Create arrays of property names
@@ -37,7 +38,7 @@ export const capitalize = (s) => {
  * Used to convert numeric values from one unit to another. Works on
  * n-dimensional arrays and implemented as a relatively simple for loop for
  * performance. If conversion times become an issue, it might be worthwhile to
- * look at vectorization with SIMD.
+ * look at vectorization with WebAssembly.
  *
  * @param {*} value The values to convert
  * @param {*} from Original unit.
@@ -75,6 +76,38 @@ export function convert(value, from, to) {
   return newValue
 }
 
+/**
+ * Used to calculate the distance between two n-dimensional points,
+ *
+ * @param {*} a First point
+ * @param {*} b Second point
+ *
+ * @return {*} Euclidean distance between the given two points.
+ */
+export function distance(a, b) {
+  return a
+    .map((x, i) => Math.abs(x - b[i]) ** 2) // square the difference
+    .reduce((sum, now) => sum + now) ** // sum
+    (1 / 2)
+}
+
+/**
+ * Used to merge two Javascript objects into a new third object by recursively
+ * overwriting and extending the target object with properties from the source
+ * object.
+ *
+ * @param {*} target The values to convert
+ * @param {*} source Original unit.
+ *
+ * @return {*} A copy of the original data with units converted.
+ */
+export function mergeObjects(source, target, copy = false) {
+  // First create a deep clone that will be used as the returned object
+  let cloned = cloneDeep(target)
+  let val = merge(cloned, source)
+  return val
+}
+
 export function arraysEqual(_arr1, _arr2) {
   if (!Array.isArray(_arr1) || !Array.isArray(_arr2) || _arr1.length !== _arr2.length) {
     return false
diff --git a/gui/yarn.lock b/gui/yarn.lock
index 42128dd22622948260812f8f9a9f757b1734d124..50868f94c665877352d4e998f1dab451687ef88f 100644
--- a/gui/yarn.lock
+++ b/gui/yarn.lock
@@ -9041,6 +9041,11 @@ pkg-up@^2.0.0:
   dependencies:
     find-up "^2.1.0"
 
+plotly.js-cartesian-dist-min@^1.54.7:
+  version "1.54.7"
+  resolved "https://registry.yarnpkg.com/plotly.js-cartesian-dist-min/-/plotly.js-cartesian-dist-min-1.54.7.tgz#f44fc89a3221d89486efe8d25a19efb5a35156d1"
+  integrity sha512-8aCk2HQ9rYQZp0ivpkq1wHtByAiPEPBFIw+jMC5elV3/WLXuZvNjwRqnXECLMQiuEilEsEZhmWisp1/QvQ06JA==
+
 pn@^1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/pn/-/pn-1.1.0.tgz#e2f4cef0e219f463c179ab37463e4e1ecdccbafb"
diff --git a/nomad/app/api/encyclopedia.py b/nomad/app/api/encyclopedia.py
index 48863244fe98b0ed01b882c9cf58869dba8ab186..270954d8269d8489599587595c17b93470121060 100644
--- a/nomad/app/api/encyclopedia.py
+++ b/nomad/app/api/encyclopedia.py
@@ -141,7 +141,7 @@ material_result = api.model("material_result", {
 class EncMaterialResource(Resource):
     @api.response(404, "The material does not exist")
     @api.response(200, "Metadata send", fields.Raw)
-    @api.doc("material/<material_id>")
+    @api.doc("get_material")
     @api.expect(material_query)
     @api.marshal_with(material_result, skip_none=True)
     @authenticate()
@@ -860,7 +860,7 @@ class EncCalculationsResource(Resource):
     @api.response(404, "Suggestion not found")
     @api.response(400, "Bad request")
     @api.response(200, "Metadata send", fields.Raw)
-    @api.doc("enc_calculations")
+    @api.doc("get_calculations")
     @authenticate()
     def get(self, material_id):
         """Used to return all calculations related to the given material. Also
@@ -1205,7 +1205,7 @@ class EncCalculationResource(Resource):
     @api.response(200, "Metadata send", fields.Raw)
     @api.expect(calculation_property_query, validate=False)
     @api.marshal_with(calculation_property_result, skip_none=True)
-    @api.doc("enc_calculation")
+    @api.doc("get_calculation")
     @authenticate()
     def post(self, material_id, calc_id):
         """Used to return calculation details. Some properties are not