diff --git a/src/Table/README.mdx b/src/Table/README.mdx
new file mode 100644
index 00000000..a7d8418a
--- /dev/null
+++ b/src/Table/README.mdx
@@ -0,0 +1,23 @@
+---
+name: Table
+menu: Components
+---
+
+import {Playground, Props} from 'docz';
+import Table, {Column} from './';
+
+# Table
+
+More detailed description to follow.
+
+## Examples
+
+
+
+## Table props
+
+
+
+## Column props
+
+
diff --git a/src/Table/getColumnConfigFromChildren.js b/src/Table/getColumnConfigFromChildren.js
new file mode 100644
index 00000000..a40ce026
--- /dev/null
+++ b/src/Table/getColumnConfigFromChildren.js
@@ -0,0 +1,27 @@
+import {Children} from 'react';
+
+/**
+ * Get the table's column configuration via JSX from
+ * its `children` prop, instead of as a JS object via
+ * the `columns` prop
+ *
+ * @param {Array} children - Table column configuration,
+ * must be made of Column components as exported by the
+ * Table component
+ *
+ * @returns {object} Table config as object
+ */
+
+function getColumnConfigFromChildren(children) {
+ return Children.map(children, child => {
+ if (child && child.type.displayName === 'Column') {
+ return child.props;
+ } else if (child) {
+ console.warn(
+ 'The Table component only accepts children that are instances of the Column component.'
+ );
+ }
+ }).filter(column => Boolean(column));
+}
+
+export default getColumnConfigFromChildren;
diff --git a/src/Table/getColumnWidths.js b/src/Table/getColumnWidths.js
new file mode 100644
index 00000000..e09f2bf3
--- /dev/null
+++ b/src/Table/getColumnWidths.js
@@ -0,0 +1,71 @@
+import {percentageToFraction} from '../utils/units';
+
+/**
+ * @param {string|number|undefined} width - Predefined width
+ * of a column, either absolute (number) or a percentage (string)
+ * @param {number} availableWidth - Total width of the table
+ *
+ * @returns {number} Column width in pixels
+ */
+
+export function getSingleColumnWidth(width, availableWidth) {
+ if (!width) return null;
+ if (typeof width === 'string') {
+ const fraction = percentageToFraction(width);
+ return Math.round(availableWidth * fraction);
+ } else return width;
+}
+
+/**
+ * The table uses fixed positioning, which means we can calculate
+ * the width of each column knowing only the table's configuration
+ * and the total width that the table takes up on the page.
+ *
+ * @param {Array} columns - Table column configuration
+ * @param {number} totalWidth - Total table width
+ *
+ * @returns {Array} column widths, containing one object per column
+ * with the fields `name`, `width`, and `shouldHide`
+ *
+ * `shouldHide` is determined based on the column's `minWidth` prop.
+ */
+
+function getColumnWidths(columns, totalWidth) {
+ if (columns.length === 0 || !totalWidth || totalWidth < 0) {
+ return null;
+ }
+ // Get the width of any columns that have a predefined width (either % or px)
+ const predefinedColumnWidths = columns.map(column =>
+ getSingleColumnWidth(column.width, totalWidth)
+ );
+ // Calculate the space remaining after subtracting the defined widths
+ const remainingSpace = predefinedColumnWidths.reduce(
+ (remaining, current) => remaining - (current || 0),
+ totalWidth
+ );
+ const remainingColumns = predefinedColumnWidths.filter(width => !width);
+ // Calculate the widths of any remaining columns by evenly
+ // distributing the remaining space between them
+ const columnWidths = predefinedColumnWidths.map(width => {
+ if (width) return width;
+ return Math.round(remainingSpace / remainingColumns.length);
+ });
+
+ // Build the output object including the information whether the
+ // column should be hidden given its size
+ const columnWidthsAndHiddenStatus = columns.map((column, index) => {
+ const shouldHide = column.minWidth
+ ? columnWidths[index] < column.minWidth
+ : false;
+
+ return {
+ name: column.name,
+ width: columnWidths[index],
+ shouldHide,
+ };
+ });
+
+ return columnWidthsAndHiddenStatus;
+}
+
+export default getColumnWidths;
diff --git a/src/Table/getColumnsToHide.js b/src/Table/getColumnsToHide.js
new file mode 100644
index 00000000..99d85150
--- /dev/null
+++ b/src/Table/getColumnsToHide.js
@@ -0,0 +1,114 @@
+import getColumnWidths from './getColumnWidths';
+
+/**
+ * Calculates which columns should be hidden at the width
+ * available to the table. It tries to fit as many columns
+ * as possible given the constraints defined by the column
+ * configuration (width & minWidth)
+ *
+ * @param {Array} columns - Table column configuration
+ * @param {number} totalWidth - Total table width
+ *
+ * @returns {Array} Names of columns that should be hidden at the given width
+ */
+
+function getColumnsToHide(columns, totalWidth) {
+ if (!totalWidth) return [];
+
+ // Calculate initial column widths if all columns are visible
+ const columnWidths = getColumnWidths(columns, totalWidth);
+
+ // Get the initial candidates for hiding (i.e. columns whose
+ // calculated width is below their defined `minWidth`)
+ const hidingCandidates = columnWidths.filter(column => column.shouldHide);
+
+ // If there's just one candidate, happy days: return it
+ if (hidingCandidates.length === 1) {
+ return [hidingCandidates[0].name];
+ }
+ // If there are more, we must determine which ones _could_ fit
+ // if only _some_ of the candidates were removed, in order to
+ // fit the maximum number of columns
+ else if (hidingCandidates.length > 1) {
+ // We'll assign a score to each candidate based on how
+ // many of the other candidates could be fit if it was hidden
+ const candidateScores = hidingCandidates.map(({name}) => {
+ // What are our widths if we hide this candidate?
+ const widthsWithoutCandidate = getColumnWidths(
+ columns.filter(c => c.name !== name),
+ totalWidth
+ );
+
+ // Get all candidates excluding the current one to
+ // check if it could be made visible now
+ const otherCandidates = hidingCandidates
+ .filter(candidate => candidate.name !== name)
+ .map(candidate => candidate.name);
+
+ let score = 0;
+ otherCandidates.forEach(otherCandidateName => {
+ const otherCandidate = widthsWithoutCandidate.find(
+ item => item.name === otherCandidateName
+ );
+ // Increase the score of this 'other candidate' if hiding
+ // the 'current candidate' has given it enough space
+ // to be made visible again
+ if (otherCandidate.shouldHide === false) {
+ score += 1;
+ }
+ });
+ return {
+ name,
+ score,
+ };
+ });
+
+ // Once we know the scores, we can group the candidates into 'final
+ // candidates' with a high score which should be hidden immediately
+ // (because hiding them creates a lot of room for other candidates)
+ // and those where hiding them doesn't change much, in which case
+ // we'll give them another pass, this time with the final
+ // candidates already out of the picture.
+ const finalCandidates = [];
+ const remainingCandidates = [];
+ // Set the threshold at the lowest score found.
+ // Anything higher we consider a final candidate
+ const lowestScore = Math.min(
+ ...candidateScores.map(item => item.score)
+ );
+
+ candidateScores.forEach(item => {
+ if (item.score > lowestScore) {
+ finalCandidates.push(item.name);
+ } else {
+ remainingCandidates.push(item.name);
+ }
+ });
+
+ // If there are no candidates with a higher score than others,
+ // we simply sacrifice and hide the rightmost candidate.
+ // Gotta do _something_ to make those columns fit, otherwise we'd
+ // enter an infinite loop!
+ if (!finalCandidates.length) {
+ finalCandidates.push(remainingCandidates.pop());
+ }
+
+ // As long as there are remaining candidates, we recursively call
+ // this function with high-scoring candidates removed, until we can
+ // fit the highest number of remaining columns.
+ if (remainingCandidates.length) {
+ const columnsWithoutFinalCandidates = columns.filter(
+ column => !finalCandidates.includes(column.name)
+ );
+ return [
+ ...finalCandidates,
+ ...getColumnsToHide(columnsWithoutFinalCandidates, totalWidth),
+ ];
+ } else return finalCandidates;
+ }
+
+ // Return an empty array if there's nothing to hide
+ return [];
+}
+
+export default getColumnsToHide;
diff --git a/src/Table/index.js b/src/Table/index.js
new file mode 100644
index 00000000..a3b9d011
--- /dev/null
+++ b/src/Table/index.js
@@ -0,0 +1,182 @@
+import React, {useRef} from 'react';
+import PropTypes from 'prop-types';
+import styled, {css} from 'styled-components';
+import {useSize} from 'react-hook-size';
+
+import {pxToRem} from 'base5-ui/utils/units';
+import {alpha} from 'base5-ui/utils/colors';
+import {borderValue} from 'base5-ui/mixins';
+import {positionProps, marginProps} from 'base5-ui/styleProps';
+import {getSpacing} from 'base5-ui/utils/spacing';
+
+import Box from 'base5-ui/Box';
+import Text from 'base5-ui/Text';
+
+import getColumnsToHide from './getColumnsToHide';
+import getColumnConfigFromChildren from './getColumnConfigFromChildren';
+
+const StyledTable = styled.table`
+ position: relative;
+ ${positionProps}
+ display: table;
+ table-layout: fixed;
+ border-spacing: 0;
+
+ ${marginProps}
+
+ width: 100%;
+
+ tr:hover {
+ background-color: ${p => alpha(p.theme.shade, p.theme.shadeStrength)};
+ }
+
+ td,
+ th {
+ font-weight: inherit;
+ text-align: left;
+ height: ${p => pxToRem(p.rowMinHeight)};
+ padding: 0 ${p => p.theme.globals.spacing.xs};
+ }
+
+ thead th {
+ position: sticky;
+ top: 0;
+ z-index: ${p => p.theme.globals.z.raised};
+ background-color: ${p => p.theme.background};
+ border-bottom: ${p => borderValue(p.theme)};
+ }
+
+ ${p =>
+ p.pl &&
+ css`
+ th:first-child,
+ td:first-child {
+ padding-left: ${getSpacing(p.pl, p.theme)};
+ }
+ `}
+
+ ${p =>
+ p.pr &&
+ css`
+ th:last-child,
+ td:last-child {
+ padding-right: ${getSpacing(p.pr, p.theme)};
+ }
+ `}
+`;
+
+const defaultHeaderRenderer = column => {column.name};
+
+// Uses either the column's name or its cellRenderer
+// to access a cell's data. If no cellRenderer is proviced,
+// the name of the column is used.
+function getCellContent(item, key) {
+ if (typeof key === 'string') {
+ return item[key] || item[key.toLowerCase()] || 'No entry found';
+ }
+ return key(item);
+}
+
+function Table({
+ children,
+ columns: columnsProp,
+ data,
+ headerRenderer = defaultHeaderRenderer,
+ rowMinHeight,
+ ...otherProps
+}) {
+ const ref = useRef();
+ const {width: tableWidth} = useSize(ref);
+ const columns = children
+ ? getColumnConfigFromChildren(children)
+ : columnsProp;
+
+ const hiddenColumns = getColumnsToHide(columns, tableWidth);
+
+ return (
+
+
+
+
+ {columns.map(column => {
+ return (
+
+ {headerRenderer(column)}
+ |
+ );
+ })}
+
+
+
+ {data.map(item => (
+
+ {columns.map(column => {
+ const elementToRender = column.isHeading
+ ? 'th'
+ : 'td';
+ const isHidden = hiddenColumns.includes(
+ column.name
+ );
+ return (
+
+ {getCellContent(
+ item,
+ column.cellRenderer || column.name
+ )}
+
+ );
+ })}
+
+ ))}
+
+
+
+ );
+}
+
+Table.defaultProps = {
+ rowMinHeight: 45,
+};
+
+Table.propTypes = {
+ rowMinHeight: PropTypes.number,
+ data: PropTypes.array.isRequired,
+ columns: PropTypes.arrayOf(PropTypes.shape(columnPropsShape)),
+};
+
+const columnPropsShape = {
+ isHeading: PropTypes.bool,
+ name: PropTypes.string.isRequired,
+ cellRenderer: PropTypes.func,
+ width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+ minWidth: PropTypes.number,
+ allowLineBreaks: PropTypes.bool,
+};
+
+function Column() {
+ return null;
+}
+Column.displayName = 'Column';
+Column.propTypes = columnPropsShape;
+
+export {Column};
+export default Table;
diff --git a/src/index.js b/src/index.js
index 8f5d7102..5ee6f621 100644
--- a/src/index.js
+++ b/src/index.js
@@ -18,6 +18,7 @@ export {default as Portal} from './Portal';
export {default as SimpleChart} from './charts/SimpleChart';
export {default as SimpleGauge} from './charts/SimpleGauge';
export {default as Switch} from './Switch';
+export {default as Table} from './Table';
export {default as Text} from './Text';
export {default as TextLink} from './TextLink';
export {default as ThemeSection} from './ThemeSection';
diff --git a/src/utils/units.js b/src/utils/units.js
index afc980c8..b89b4419 100644
--- a/src/utils/units.js
+++ b/src/utils/units.js
@@ -13,4 +13,18 @@ const pxToRem = px => pxToRelative(px, rootFontSize, 'rem');
const pxToEm = (px, base = rootFontSize) => pxToRelative(px, base, 'em');
-export {rootFontSize, remToPx, pxToRem, pxToEm};
+/**
+ * Turns a percentage string, e.g. '57%', into a fraction (0.57)
+ *
+ * @param {string} percentage - Percentage value ending with '%'
+ *
+ * @returns {number} Percentage as fraction (0..1)
+ */
+
+function percentageToFraction(percentage) {
+ if (!percentage || typeof percentage !== 'string') return null;
+
+ return Number(percentage.slice(0, -1)) / 100;
+}
+
+export {rootFontSize, remToPx, pxToRem, pxToEm, percentageToFraction};