diff --git a/soitool/modules/module_code_phrase.py b/soitool/modules/module_code_phrase.py
new file mode 100644
index 0000000000000000000000000000000000000000..c33ec30858601c14fd5a1339e5afccd31b6d303a
--- /dev/null
+++ b/soitool/modules/module_code_phrase.py
@@ -0,0 +1,175 @@
+from PySide2.QtWidgets import QTableWidget, QTableWidgetItem
+from PySide2 import QtGui, QtCore
+from soitool.modules.module_base import (
+    ModuleBase,
+    resize_table,
+    get_table_size,
+    HEADLINE_FONT,
+)
+
+START_ROWS = 2
+
+
+class Meta(type(ModuleBase), type(QTableWidget)):
+    """Used as a metaclass to enable multiple inheritance."""
+
+
+# TODO write docstring
+class CodePhraseModule(ModuleBase, QTableWidget, metaclass=Meta):
+    """Modified QTableWidget.
+
+    By default, the widget initializes as an empty START_ROWS * START_COLUMNS
+    table. If parameters are given, the table initializes accordingly:
+    'size' is a dict: {"width": int, "height": int},
+    'data' is a 2D list where data[x][y] represents row x, column y.
+
+    The widget does not use more room than needed, and resizes dynamically.
+    Columnheaders are styled with light grey background and bold text.
+    It has shortcuts for adding and removing rows and columns.
+
+    Inherits from ModuleBase and QTableWidget.
+    ModuleBase is used as an interface, it's methods are overridden.
+    """
+
+    def __init__(self, size=None, data=None):
+        self.type = "CodePhraseModule"
+        QTableWidget.__init__(self, 0, 2)
+        ModuleBase.__init__(self)
+
+        # Remove headers and scrollbars
+        self.horizontalHeader().hide()
+        self.verticalHeader().hide()
+        self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
+        self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
+
+        if size is None and data is None:
+            for _ in range(START_ROWS):
+                self.add_row()
+        else:
+            # TODO check if copy-pasted block below can be used
+            self.setColumnCount(len(data[0]))
+            self.setRowCount(len(data))
+
+            # Set header-items
+            for i in range(self.columnCount()):
+                self.set_header_item(i, data[0][i])
+
+            # Set cell-items
+            for i in range(1, self.rowCount()):
+                for j in range(self.columnCount()):
+                    item = QTableWidgetItem(data[i][j])
+                    self.setItem(i, j, item)
+
+            self.resizeColumnsToContents()
+            self.resizeRowsToContents()
+            self.setFixedWidth(size["width"])
+            self.setFixedHeight(size["height"])
+
+        self.cellChanged.connect(lambda: resize_table(self))
+
+
+    # TODO update keybindings
+    # new row -> coded row with empty text
+    def keyPressEvent(self, event):
+        """Launch actions when specific combinations of keys are pressed.
+
+        If the keys pressed are not related to a shortcut on this custom widget
+        the event is sent on to be handled by the superclass (for navigation
+        with arrow-keys for.eg.)
+
+        Parameters
+        ----------
+        event : QKeyEvent
+            event sent by Qt for us to handle
+        """
+        if event.key() == QtCore.Qt.Key_Question:
+            self.add_column()
+        elif (
+            event.modifiers() == QtCore.Qt.ShiftModifier
+            and event.key() == QtCore.Qt.Key_Underscore
+        ):
+            self.remove_column()
+        elif (
+            event.modifiers() == QtCore.Qt.ControlModifier
+            and event.key() == QtCore.Qt.Key_Plus
+        ):
+            self.add_row()
+        elif (
+            event.modifiers() == QtCore.Qt.ControlModifier
+            and event.key() == QtCore.Qt.Key_Underscore
+        ):
+            self.remove_row()
+        else:
+            super(CodePhraseModule, self).keyPressEvent(event)
+
+    def set_header_item(self, column, text):
+        """Insert item with header-style.
+
+        Item will always be set on header (top) row.
+
+        Parameters
+        ----------
+        column : int
+            What column index for inserting item.
+        text : String
+            What text the item should contain.
+        """
+        item = QTableWidgetItem(text)
+        item.setBackground(QtGui.QBrush(QtGui.QColor(220, 220, 220)))
+        item.setFont(HEADLINE_FONT)
+        self.setItem(0, column, item)
+
+    def add_row(self):
+        """Add row below selected row."""
+        self.insertRow(self.currentRow() + 1)
+        item = QTableWidgetItem("koko")
+        self.setItem(self.currentRow() + 1,0, item)
+        resize_table(self)
+
+    def remove_row(self):
+        """Remove selected row if two or more rows exist (including header)."""
+        if self.rowCount() > 2 and self.currentRow() != 0:
+            self.removeRow(self.currentRow())
+            resize_table(self)
+
+    def get_size(self):
+        """Return size of widget."""
+        return get_table_size(self)
+
+    def get_data(self):
+        """Return list containing module data.
+
+        Returns
+        -------
+        List (2D)
+            list[x][y] represents value of row x, column y.
+        """
+        content = []
+        for i in range(self.rowCount()):
+            row = []
+            for j in range(self.columnCount()):
+                item = self.item(i, j)
+                if item is not None:
+                    row.append(item.text())
+                else:
+                    row.append("")
+            content.append(row)
+
+        return content
+
+    @staticmethod
+    def get_user_friendly_name():
+        """Get user-friendly name of module."""
+        return "Generisk tabell"
+
+    @staticmethod
+    def get_icon():
+        """Get icon of module."""
+        return QtGui.QIcon("soitool/media/tablemodule.png")
+
+if __name__ == '__main__':
+    from PySide2.QtWidgets import QApplication
+    app = QApplication()
+    w = CodePhraseModule()
+    w.show()
+    app.exec_()