diff --git a/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactCard.kt b/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactCard.kt
index 402faf1d7cbe9f5957ff7c72058c2dbbe797150a..911a05eec12604178e961ae9ec1b5489ed6919d6 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactCard.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactCard.kt
@@ -1,6 +1,5 @@
 package org.briarproject.briar.desktop.contact
 
-import androidx.compose.desktop.ui.tooling.preview.Preview
 import androidx.compose.foundation.Canvas
 import androidx.compose.foundation.background
 import androidx.compose.foundation.border
@@ -19,7 +18,6 @@ import androidx.compose.foundation.shape.CircleShape
 import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.material.Card
 import androidx.compose.material.MaterialTheme
-import androidx.compose.material.Surface
 import androidx.compose.material.Text
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Alignment
@@ -30,36 +28,38 @@ import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.sp
 import org.briarproject.bramble.api.contact.ContactId
 import org.briarproject.bramble.api.identity.AuthorId
-import org.briarproject.briar.desktop.theme.DarkColors
 import org.briarproject.briar.desktop.theme.outline
 import org.briarproject.briar.desktop.theme.selectedCard
 import org.briarproject.briar.desktop.theme.surfaceVariant
 import org.briarproject.briar.desktop.ui.Constants.HEADER_SIZE
 import org.briarproject.briar.desktop.ui.HorizontalDivider
 import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18n
+import org.briarproject.briar.desktop.utils.PreviewUtils.preview
 import org.briarproject.briar.desktop.utils.TimeUtils.getFormattedTimestamp
 import java.time.Instant
 
-@Preview
-@Composable
-fun PreviewContactCard() {
-    MaterialTheme(colors = DarkColors) {
-        Surface {
-            ContactCard(
-                ContactItem(
-                    contactId = ContactId(0),
-                    authorId = AuthorId(ByteArray(0)),
-                    name = "Paul",
-                    alias = "UI Master",
-                    isConnected = true,
-                    isEmpty = false,
-                    unread = 3,
-                    timestamp = Instant.now().epochSecond
-                ),
-                {}, false
-            )
-        }
-    }
+fun main() = preview(
+    "name" to "Paul",
+    "alias" to "UI Master",
+    "isConnected" to true,
+    "isEmpty" to false,
+    "unread" to 3,
+    "timestamp" to Instant.now().toEpochMilli(),
+    "selected" to false,
+) {
+    ContactCard(
+        ContactItem(
+            contactId = ContactId(0),
+            authorId = AuthorId(getRandomId()),
+            name = getStringParameter("name"),
+            alias = getStringParameter("alias"),
+            isConnected = getBooleanParameter("isConnected"),
+            isEmpty = getBooleanParameter("isEmpty"),
+            unread = getIntParameter("unread"),
+            timestamp = getLongParameter("timestamp")
+        ),
+        {}, getBooleanParameter("selected")
+    )
 }
 
 @Composable
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/conversation/TextBubble.kt b/src/main/kotlin/org/briarproject/briar/desktop/conversation/TextBubble.kt
index 47f6711eb5ce775da9a575d60b64f0b315c0b551..044eba7d4187c805e1a212668d2b8a61419839b8 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/conversation/TextBubble.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/conversation/TextBubble.kt
@@ -19,10 +19,37 @@ import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.sp
+import org.briarproject.bramble.api.sync.GroupId
+import org.briarproject.bramble.api.sync.MessageId
 import org.briarproject.briar.desktop.theme.awayMsgBubble
 import org.briarproject.briar.desktop.theme.localMsgBubble
 import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18n
+import org.briarproject.briar.desktop.utils.PreviewUtils.preview
 import org.briarproject.briar.desktop.utils.TimeUtils
+import java.time.Instant
+
+fun main() = preview(
+    "text" to "Lorem ipsum dolor sit amet.",
+    "time" to Instant.now().toEpochMilli(),
+    "isIncoming" to false,
+    "isRead" to false,
+    "isSent" to false,
+    "isSeen" to false,
+) {
+    TextBubble(
+        ConversationMessageItem(
+            text = getStringParameter("text"),
+            id = MessageId(getRandomId()),
+            groupId = GroupId(getRandomId()),
+            time = getLongParameter("time"),
+            autoDeleteTimer = 0,
+            isIncoming = getBooleanParameter("isIncoming"),
+            isRead = getBooleanParameter("isRead"),
+            isSent = getBooleanParameter("isSent"),
+            isSeen = getBooleanParameter("isSeen"),
+        )
+    )
+}
 
 @Composable
 fun TextBubble(m: ConversationMessageItem) {
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/utils/PreviewUtils.kt b/src/main/kotlin/org/briarproject/briar/desktop/utils/PreviewUtils.kt
new file mode 100644
index 0000000000000000000000000000000000000000..d03fd2bc25692a112236c9fbbf7efd4ea3ca0723
--- /dev/null
+++ b/src/main/kotlin/org/briarproject/briar/desktop/utils/PreviewUtils.kt
@@ -0,0 +1,134 @@
+package org.briarproject.briar.desktop.utils
+
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.material.Icon
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Surface
+import androidx.compose.material.Text
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Done
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.State
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.singleWindowApplication
+import org.briarproject.bramble.api.UniqueId
+import org.briarproject.briar.desktop.theme.DarkColors
+import org.briarproject.briar.desktop.theme.LightColors
+import kotlin.random.Random
+
+object PreviewUtils {
+
+    class PreviewScope {
+        private val random = Random(0)
+
+        val parameters = mutableMapOf<String, State<Any>>()
+
+        private inline fun <reified T> getDatatype(name: String): T {
+            val state = parameters[name] ?: throw IllegalArgumentException("No parameter found with name '$name'")
+            if (state.value !is T) throw IllegalArgumentException("Parameter '$name' is not of type ${T::class.simpleName}")
+            return state.value as T
+        }
+
+        fun getStringParameter(name: String) = getDatatype<String>(name)
+
+        fun getBooleanParameter(name: String) = getDatatype<Boolean>(name)
+
+        fun getIntParameter(name: String) = getDatatype<Int>(name)
+
+        fun getLongParameter(name: String) = getDatatype<Long>(name)
+
+        @Composable
+        fun getRandomId() =
+            remember { random.nextBytes(UniqueId.LENGTH) }
+    }
+
+    @Composable
+    private fun <T : Any> PreviewScope.addParameter(
+        name: String,
+        initial: T,
+        editField: @Composable (MutableState<T>) -> Unit
+    ) {
+        val value = remember { mutableStateOf(initial) }
+
+        Row {
+            Text("$name: ")
+            editField(value)
+        }
+
+        parameters[name] = value
+    }
+
+    @Composable
+    private fun PreviewScope.addStringParameter(name: String, initial: String) = addParameter(name, initial) { value ->
+        BasicTextField(value.value, { value.value = it })
+    }
+
+    @Composable
+    private fun PreviewScope.addBooleanParameter(name: String, initial: Boolean) =
+        addParameter(name, initial) { value ->
+            Box(modifier = Modifier.size(15.dp).border(1.dp, Color.Black).clickable { value.value = !value.value }) {
+                if (value.value) Icon(Icons.Filled.Done, "")
+            }
+        }
+
+    @Composable
+    private fun PreviewScope.addIntParameter(name: String, initial: Int) = addParameter(name, initial) { value ->
+        BasicTextField(value.value.toString(), { value.value = it.toInt() })
+    }
+
+    @Composable
+    private fun PreviewScope.addLongParameter(name: String, initial: Long) = addParameter(name, initial) { value ->
+        BasicTextField(value.value.toString(), { value.value = it.toLong() })
+    }
+
+    /**
+     * Open an interactive preview of the composable specified by [content].
+     * All [parameters] passed to this function will be changeable on the fly.
+     * They can be retrieved as [State] using [PreviewScope.getStringParameter] or similar functions
+     * and used inside the composable [content].
+     */
+    fun preview(
+        vararg parameters: Pair<String, Any>,
+        content: @Composable PreviewScope.() -> Unit
+    ) {
+        val scope = PreviewScope()
+
+        singleWindowApplication(title = "Interactive Preview") {
+            Column {
+                Column(Modifier.padding(10.dp)) {
+                    scope.addBooleanParameter("darkTheme", true)
+                    parameters.forEach { (name, initial) ->
+                        when (initial) {
+                            is String -> scope.addStringParameter(name, initial)
+                            is Boolean -> scope.addBooleanParameter(name, initial)
+                            is Int -> scope.addIntParameter(name, initial)
+                            is Long -> scope.addLongParameter(name, initial)
+                            else -> throw IllegalArgumentException("Type ${initial::class.simpleName} is not supported for previewing.")
+                        }
+                    }
+                }
+
+                MaterialTheme(colors = if (scope.getBooleanParameter("darkTheme")) DarkColors else LightColors) {
+                    Surface(Modifier.fillMaxSize(1f)) {
+                        Column(Modifier.padding(10.dp)) {
+                            content(scope)
+                        }
+                    }
+                }
+            }
+        }
+    }
+}