diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/Main.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/Main.kt
index 7435413515bbe7ad25a7e9b8ef055126abb0d895..f0eb6e3fe2b496e2b72d9e6705be0a517ed228b9 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/Main.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/Main.kt
@@ -115,6 +115,7 @@ private class Main : CliktCommand(
             DaggerBriarDesktopApp.builder().desktopCoreModule(
                 DesktopCoreModule(dataDir, socksPort, controlPort)
             ).build()
+
         // We need to load the eager singletons directly after making the
         // dependency graphs
         BrambleCoreEagerSingletons.Helper.injectEagerSingletons(app)
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/add/remote/AddContactDialog.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/add/remote/AddContactDialog.kt
index bd5e773b4ff4f47395c732417e232dc34b5ec0e6..632d99d22115b9544d6cfdfde2bfc722a8347282 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/add/remote/AddContactDialog.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/add/remote/AddContactDialog.kt
@@ -56,6 +56,7 @@ import androidx.compose.material.icons.filled.SouthWest
 import androidx.compose.material.icons.filled.Warning
 import androidx.compose.material.rememberScaffoldState
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.ui.Alignment
@@ -67,6 +68,7 @@ import androidx.compose.ui.input.pointer.PointerIconDefaults
 import androidx.compose.ui.input.pointer.pointerHoverIcon
 import androidx.compose.ui.platform.ClipboardManager
 import androidx.compose.ui.platform.LocalClipboardManager
+import androidx.compose.ui.platform.LocalDensity
 import androidx.compose.ui.semantics.Role
 import androidx.compose.ui.semantics.clearAndSetSemantics
 import androidx.compose.ui.semantics.contentDescription
@@ -104,8 +106,8 @@ import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18n
 import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18nF
 import org.briarproject.briar.desktop.utils.PreviewUtils
 import org.briarproject.briar.desktop.utils.PreviewUtils.preview
+import org.briarproject.briar.desktop.utils.UiUtils.DensityDimension
 import org.briarproject.briar.desktop.viewmodel.viewModel
-import java.awt.Dimension
 
 @Suppress("HardCodedStringLiteral")
 const val link = "briar://ady23gvb2r76afe5zhxh5kvnh4b22zrcnxibn63tfknrdcwrw7zrs"
@@ -196,6 +198,7 @@ fun AddContactDialog(
     if (!visible) {
         return
     }
+    val density = LocalDensity.current
     Dialog(
         title = i18n("contact.add.title_dialog"),
         onCloseRequest = onClose,
@@ -204,67 +207,70 @@ fun AddContactDialog(
             size = DpSize(width = 560.dp, height = 520.dp),
         ),
     ) {
-        window.minimumSize = Dimension(360, 512)
-        val clipboardManager = LocalClipboardManager.current
-        val scaffoldState = rememberScaffoldState()
-        val coroutineScope = rememberCoroutineScope()
-        val aliasFocusRequester = remember { FocusRequester() }
-        Surface {
-            Scaffold(
-                modifier = Modifier.padding(horizontal = 24.dp).padding(top = 24.dp, bottom = 12.dp),
-                topBar = {
-                    Box(Modifier.fillMaxWidth()) {
-                        Text(
-                            i18n("contact.add.remote.title"),
-                            style = MaterialTheme.typography.h6,
-                            modifier = Modifier.padding(bottom = 12.dp)
-                        )
-                    }
-                },
-                scaffoldState = scaffoldState,
-                content = {
-                    Column(Modifier.fillMaxSize()) {
-                        if (error != null) {
-                            AddContactErrorDialog(error, onErrorDialogDismissed)
+        CompositionLocalProvider(LocalDensity provides density) {
+            window.minimumSize = DensityDimension(360, 512)
+            window.preferredSize = DensityDimension(520, 512)
+            val clipboardManager = LocalClipboardManager.current
+            val scaffoldState = rememberScaffoldState()
+            val coroutineScope = rememberCoroutineScope()
+            val aliasFocusRequester = remember { FocusRequester() }
+            Surface {
+                Scaffold(
+                    modifier = Modifier.padding(horizontal = 24.dp).padding(top = 24.dp, bottom = 12.dp),
+                    topBar = {
+                        Box(Modifier.fillMaxWidth()) {
+                            Text(
+                                i18n("contact.add.remote.title"),
+                                style = MaterialTheme.typography.h6,
+                                modifier = Modifier.padding(bottom = 12.dp)
+                            )
                         }
-                        OwnLink(
-                            handshakeLink,
-                            clipboardManager,
-                            coroutineScope,
-                            scaffoldState,
-                        )
-                        ContactLink(
-                            remoteHandshakeLink,
-                            setRemoteHandshakeLink,
-                            clipboardManager,
-                            coroutineScope,
-                            scaffoldState,
-                            aliasFocusRequester,
-                        )
-                        Alias(
-                            alias,
-                            setAddContactAlias,
-                            aliasFocusRequester,
-                            onSubmitAddContactDialog,
-                        )
-                    }
-                },
-                bottomBar = {
-                    Box(Modifier.fillMaxWidth()) {
-                        Row(Modifier.align(Alignment.CenterEnd)) {
-                            TextButton(
-                                onClose,
-                                colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colors.error)
-                            ) {
-                                Text(i18n("cancel"))
+                    },
+                    scaffoldState = scaffoldState,
+                    content = {
+                        Column(Modifier.fillMaxSize()) {
+                            if (error != null) {
+                                AddContactErrorDialog(error, onErrorDialogDismissed)
                             }
-                            Button(onSubmitAddContactDialog, modifier = Modifier.padding(start = 8.dp)) {
-                                Text(i18n("add"))
+                            OwnLink(
+                                handshakeLink,
+                                clipboardManager,
+                                coroutineScope,
+                                scaffoldState,
+                            )
+                            ContactLink(
+                                remoteHandshakeLink,
+                                setRemoteHandshakeLink,
+                                clipboardManager,
+                                coroutineScope,
+                                scaffoldState,
+                                aliasFocusRequester,
+                            )
+                            Alias(
+                                alias,
+                                setAddContactAlias,
+                                aliasFocusRequester,
+                                onSubmitAddContactDialog,
+                            )
+                        }
+                    },
+                    bottomBar = {
+                        Box(Modifier.fillMaxWidth()) {
+                            Row(Modifier.align(Alignment.CenterEnd)) {
+                                TextButton(
+                                    onClose,
+                                    colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colors.error)
+                                ) {
+                                    Text(i18n("cancel"))
+                                }
+                                Button(onSubmitAddContactDialog, modifier = Modifier.padding(start = 8.dp)) {
+                                    Text(i18n("add"))
+                                }
                             }
                         }
-                    }
-                },
-            )
+                    },
+                )
+            }
         }
     }
 }
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/settings/SettingDetails.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/settings/SettingDetails.kt
index 95321ecf0a76ee4e7abf8fa968ca4fb4831824d6..ff075b6b623f66560eb5c32849ba81497b69a5ca 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/settings/SettingDetails.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/settings/SettingDetails.kt
@@ -29,18 +29,25 @@ import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
 import androidx.compose.foundation.layout.widthIn
 import androidx.compose.material.Icon
 import androidx.compose.material.MaterialTheme
 import androidx.compose.material.OutlinedButton
 import androidx.compose.material.OutlinedExposedDropDownMenu
+import androidx.compose.material.Slider
 import androidx.compose.material.Switch
 import androidx.compose.material.Text
 import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.FormatSize
 import androidx.compose.material.icons.filled.Warning
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.scale
+import androidx.compose.ui.platform.LocalDensity
 import androidx.compose.ui.semantics.contentDescription
 import androidx.compose.ui.semantics.semantics
 import androidx.compose.ui.unit.dp
@@ -90,6 +97,33 @@ fun SettingDetails(viewModel: SettingsViewModel) {
                     )
                 }
 
+                // TODO: add description
+                DetailItem(
+                    label = i18n("settings.display.ui_scale.title"),
+                    description = ""
+                ) {
+                    val uiScale = remember { mutableStateOf(viewModel.selectedUiScale.value) }
+
+                    Row(
+                        horizontalArrangement = Arrangement.spacedBy(2.dp),
+                        verticalAlignment = Alignment.CenterVertically,
+                        modifier = Modifier.width(200.dp)
+                    ) {
+                        Icon(Icons.Default.FormatSize, null, Modifier.scale(0.7f))
+                        Slider(
+                            value = uiScale.value ?: LocalDensity.current.density,
+                            onValueChange = { uiScale.value = it },
+                            onValueChangeFinished = { viewModel.selectUiScale(uiScale.value!!) },
+                            valueRange = 1f..3f,
+                            steps = 3,
+                            // todo: without setting the width explicitly,
+                            //  the slider takes up the whole remaining space
+                            modifier = Modifier.width(150.dp)
+                        )
+                        Icon(Icons.Default.FormatSize, null)
+                    }
+                }
+
                 DetailItem(
                     label = i18n("settings.security.title"),
                     description = i18n("access.settings.click_to_change_password")
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/settings/SettingsViewModel.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/settings/SettingsViewModel.kt
index 54882686a48455e4a43eef64eb81c870f971dde1..397c971630ba66b8a394548ba8b8c04548932cc9 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/settings/SettingsViewModel.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/settings/SettingsViewModel.kt
@@ -61,6 +61,9 @@ constructor(
     private val _selectedLanguage = mutableStateOf(unencryptedSettings.language)
     val selectedLanguage = _selectedLanguage.asState()
 
+    private val _selectedUiScale = mutableStateOf(unencryptedSettings.uiScale)
+    val selectedUiScale = _selectedUiScale.asState()
+
     private val _changePasswordDialogVisible = mutableStateOf(false)
     val changePasswordDialogVisible = _changePasswordDialogVisible.asState()
 
@@ -92,6 +95,11 @@ constructor(
         briarExecutors.onIoThread { unencryptedSettings.language = language }
     }
 
+    fun selectUiScale(uiScale: Float) {
+        _selectedUiScale.value = uiScale
+        briarExecutors.onIoThread { unencryptedSettings.uiScale = uiScale }
+    }
+
     fun showChangePasswordDialog() {
         _changePasswordDialogVisible.value = true
     }
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/settings/UnencryptedSettings.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/settings/UnencryptedSettings.kt
index 30065f167e41ea7dcb17aa97e79f52719f7b30b4..7acbc6ac8482f156cc8d6c7f7945bde876a35673 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/settings/UnencryptedSettings.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/settings/UnencryptedSettings.kt
@@ -25,6 +25,7 @@ import java.util.Locale
 interface UnencryptedSettingsReadOnly {
     val theme: UnencryptedSettings.Theme
     val language: UnencryptedSettings.Language
+    val uiScale: Float?
 
     val invalidateScreen: SingleStateEvent<Unit>
 }
@@ -57,6 +58,7 @@ interface UnencryptedSettings : UnencryptedSettingsReadOnly {
 
     override var theme: Theme
     override var language: Language
+    override var uiScale: Float?
 
     override val invalidateScreen: SingleStateEvent<Unit>
 }
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/settings/UnencryptedSettingsImpl.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/settings/UnencryptedSettingsImpl.kt
index 9467e89b78fa41807d6164360acd46e5130de56d..2ea6dfb0aa147ca5797a2322588a75e16ccf2352 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/settings/UnencryptedSettingsImpl.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/settings/UnencryptedSettingsImpl.kt
@@ -33,6 +33,7 @@ import kotlin.reflect.KProperty
 
 const val PREF_THEME = "theme" // NON-NLS
 const val PREF_LANG = "language" // NON-NLS
+const val PREF_UI_SCALE = "uiScale" // NON-NLS
 
 class UnencryptedSettingsImpl @Inject internal constructor() : UnencryptedSettings {
 
@@ -40,14 +41,26 @@ class UnencryptedSettingsImpl @Inject internal constructor() : UnencryptedSettin
         private val LOG = KotlinLogging.logger {}
     }
 
-    // used for unencrypted settings, namely theme and language
+    // used for unencrypted settings, namely theme, language and UI scale factor
     private val prefs = Preferences.userNodeForPackage(this::class.java)
 
     override val invalidateScreen = SingleStateEvent<Unit>()
 
-    override var theme by EnumEntry(PREF_THEME, AUTO, Theme::class.java)
+    override var theme by EnumEntry(PREF_THEME, AUTO, Theme::class.java, invalidateScreenOnChange = true)
 
-    override var language by EnumEntry(PREF_LANG, DEFAULT, Language::class.java, ::updateLocale)
+    override var language by EnumEntry(
+        PREF_LANG,
+        DEFAULT,
+        Language::class.java,
+        onChange = ::updateLocale,
+        invalidateScreenOnChange = true
+    )
+
+    override var uiScale by FloatEntry(
+        PREF_UI_SCALE,
+        null,
+        invalidateScreenOnChange = true
+    )
 
     init {
         updateLocale(language)
@@ -57,22 +70,20 @@ class UnencryptedSettingsImpl @Inject internal constructor() : UnencryptedSettin
         InternationalizationUtils.locale = language.locale
     }
 
-    private class EnumEntry<T : Enum<*>>(
+    private open class Entry<T : Any>(
         private val key: String,
         private val default: T,
-        private val enumClass: Class<T>,
-        private val onChange: (value: T) -> Unit = {}
+        private val deserialize: (string: String) -> T?,
+        private val serialize: (value: T) -> String = { it.toString() },
+        private val onChange: (value: T) -> Unit = {},
+        private val invalidateScreenOnChange: Boolean = false,
     ) {
         private lateinit var current: T
 
         operator fun getValue(thisRef: UnencryptedSettingsImpl, property: KProperty<*>): T {
             if (!::current.isInitialized) {
-                val value = thisRef.prefs.get(key, default.name)
-                current = enumClass.enumConstants.find { it.name == value }
-                    ?: run {
-                        LOG.e { "Unexpected enum value for ${enumClass.simpleName}: $value" }
-                        default
-                    }
+                current = deserialize(thisRef.prefs.get(key, serialize(default)))
+                    ?: throw IllegalArgumentException()
             }
             return current
         }
@@ -82,10 +93,75 @@ class UnencryptedSettingsImpl @Inject internal constructor() : UnencryptedSettin
             if (current == value) return
 
             current = value
-            thisRef.prefs.put(key, value.name)
+            thisRef.prefs.put(key, serialize(value))
             thisRef.prefs.flush() // write preferences to disk
             onChange(value)
-            thisRef.invalidateScreen.emit(Unit)
+            if (invalidateScreenOnChange) thisRef.invalidateScreen.emit(Unit)
         }
     }
+
+    private open class NullableEntry<T : Any>(
+        private val key: String,
+        private val default: T?,
+        private val deserialize: (string: String?) -> T?,
+        private val serialize: (value: T?) -> String? = { it.toString() },
+        private val onChange: (value: T?) -> Unit = {},
+        private val invalidateScreenOnChange: Boolean = false,
+    ) {
+        private var read = false
+        private var current: T? = null
+
+        operator fun getValue(thisRef: UnencryptedSettingsImpl, property: KProperty<*>): T? {
+            if (!read) {
+                read = true
+                current = deserialize(thisRef.prefs.get(key, serialize(default)))
+            }
+            return current
+        }
+
+        @IoExecutor
+        operator fun setValue(thisRef: UnencryptedSettingsImpl, property: KProperty<*>, value: T?) {
+            if (current == value) return
+
+            current = value
+            if (current == default || serialize(value) == null) {
+                thisRef.prefs.remove(key)
+            } else {
+                thisRef.prefs.put(key, serialize(value))
+            }
+            thisRef.prefs.flush() // write preferences to disk
+            onChange(value)
+            if (invalidateScreenOnChange) thisRef.invalidateScreen.emit(Unit)
+        }
+    }
+
+    private class EnumEntry<T : Enum<*>>(
+        key: String,
+        default: T,
+        private val enumClass: Class<T>,
+        serialize: (value: T) -> String = { it.toString() },
+        deserialize: (string: String) -> T? = { string ->
+            enumClass.enumConstants.find {
+                serialize(it) == string
+            } ?: run {
+                LOG.e { "Unexpected enum value for ${enumClass.simpleName}: $string" }
+                default
+            }
+        },
+        onChange: (value: T) -> Unit = {},
+        invalidateScreenOnChange: Boolean = false,
+    ) : Entry<T>(
+        key, default, deserialize, serialize, onChange, invalidateScreenOnChange
+    )
+
+    private class FloatEntry(
+        key: String,
+        default: Float?,
+        deserialize: (string: String?) -> Float? = { it?.toFloatOrNull() },
+        serialize: (value: Float?) -> String? = { it?.toString() },
+        onChange: (value: Float?) -> Unit = {},
+        invalidateScreenOnChange: Boolean = false,
+    ) : NullableEntry<Float>(
+        key, default, deserialize, serialize, onChange, invalidateScreenOnChange
+    )
 }
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/theme/Theme.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/theme/Theme.kt
index a6d544da2191c7c81b4a1c6f96b844746ad3deed..e5fcb4f58203d5f0b43976e8df522b824b451b36 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/theme/Theme.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/theme/Theme.kt
@@ -33,9 +33,11 @@ import androidx.compose.material.lightColors
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.CompositionLocalProvider
 import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalDensity
 import androidx.compose.ui.text.TextStyle
 import androidx.compose.ui.text.font.FontFamily
 import androidx.compose.ui.text.platform.Font
+import androidx.compose.ui.unit.Density
 import androidx.compose.ui.unit.sp
 
 val Colors.divider: Color get() = if (isLight) Gray300 else Gray800
@@ -53,12 +55,12 @@ val Colors.textSecondary: Color get() = if (isLight) TextSecondaryMaterialLight
 val Colors.privateMessageDate: Color get() = Gray200
 val Colors.buttonTextNegative: Color get() = Red500
 val Colors.buttonTextPositive: Color get() = Blue400
-val Colors.warningBackground get() = Red500
-val Colors.warningForeground get() = Color.White
-val Colors.sendButton get() = if (isLight) Lime700 else Lime500
-val Colors.passwordStrengthWeak get() = Red500
-val Colors.passwordStrengthMiddle get() = if (isLight) Orange700 else Orange500
-val Colors.passwordStrengthStrong get() = if (isLight) Lime700 else Lime500
+val Colors.warningBackground: Color get() = Red500
+val Colors.warningForeground: Color get() = Color.White
+val Colors.sendButton: Color get() = if (isLight) Lime700 else Lime500
+val Colors.passwordStrengthWeak: Color get() = Red500
+val Colors.passwordStrengthMiddle: Color get() = if (isLight) Orange700 else Orange500
+val Colors.passwordStrengthStrong: Color get() = if (isLight) Lime700 else Lime500
 
 val DarkColors = darkColors(
     primary = Blue500,
@@ -119,6 +121,7 @@ val briarTypography = Typography(
 @Composable
 fun BriarTheme(
     isDarkTheme: Boolean = isSystemInDarkTheme(),
+    density: Float? = null,
     colors: Colors? = null,
     content: @Composable () -> Unit,
 ) = MaterialTheme(
@@ -138,6 +141,7 @@ fun BriarTheme(
     CompositionLocalProvider(
         LocalTextSelectionColors provides customTextSelectionColors,
         LocalContextMenuRepresentation provides contextMenuRepresentation,
+        LocalDensity provides if (density != null) Density(density) else LocalDensity.current,
     ) {
         Surface {
             content()
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/BriarUi.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/BriarUi.kt
index c9fe9daf2a696da2a7f9469f427a3cc414658813..897ee60ff4de569ae81ebbfe7cc4bbbae3abc969 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/BriarUi.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/BriarUi.kt
@@ -37,6 +37,7 @@ import androidx.compose.ui.graphics.toAwtImage
 import androidx.compose.ui.platform.LocalDensity
 import androidx.compose.ui.platform.LocalLayoutDirection
 import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.Density
 import androidx.compose.ui.window.FrameWindowScope
 import androidx.compose.ui.window.Window
 import org.briarproject.bramble.api.event.EventBus
@@ -62,8 +63,9 @@ import org.briarproject.briar.desktop.ui.Screen.EXPIRED
 import org.briarproject.briar.desktop.ui.Screen.MAIN
 import org.briarproject.briar.desktop.ui.Screen.STARTUP
 import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18n
+import org.briarproject.briar.desktop.utils.UiUtils.DensityDimension
+import org.briarproject.briar.desktop.utils.UiUtils.GlobalDensity
 import org.briarproject.briar.desktop.viewmodel.ViewModelProvider
-import java.awt.Dimension
 import java.awt.event.WindowEvent
 import java.awt.event.WindowFocusListener
 import javax.annotation.concurrent.Immutable
@@ -205,7 +207,13 @@ constructor(
                 }
             }
 
-            window.minimumSize = Dimension(800, 600)
+            CompositionLocalProvider(
+                LocalDensity provides Density(configuration.uiScale ?: GlobalDensity),
+            ) {
+                window.minimumSize = DensityDimension(800, 600)
+                window.preferredSize = DensityDimension(800, 600)
+            }
+
             CompositionLocalProvider(
                 LocalWindowScope provides this,
                 LocalWindowFocusState provides focusState,
@@ -221,7 +229,7 @@ constructor(
 
                 val isDarkTheme = configuration.theme == DARK ||
                     (configuration.theme == AUTO && isSystemInDarkTheme())
-                BriarTheme(isDarkTheme) {
+                BriarTheme(isDarkTheme, configuration.uiScale) {
                     Column(Modifier.fillMaxSize()) {
                         ExpirationBanner { screenState = EXPIRED; stop() }
                         when (screenState) {
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/utils/PreviewUtils.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/utils/PreviewUtils.kt
index a0ccb0b221a51a854a2498f063881ce033159b23..2c7ba9bda8f875deeb078b84dc6b7a446db00c73 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/utils/PreviewUtils.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/utils/PreviewUtils.kt
@@ -47,6 +47,8 @@ import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.Density
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.window.singleWindowApplication
 import org.briarproject.bramble.api.UniqueId
@@ -55,7 +57,9 @@ import org.briarproject.briar.desktop.theme.BriarTheme
 import org.briarproject.briar.desktop.ui.LocalWindowFocusState
 import org.briarproject.briar.desktop.ui.LocalWindowScope
 import org.briarproject.briar.desktop.ui.WindowFocusState
+import org.briarproject.briar.desktop.utils.UiUtils.DensityDimension
 import org.briarproject.briar.desktop.viewmodel.SingleStateEvent
+import java.util.prefs.Preferences
 import kotlin.random.Random
 
 object PreviewUtils {
@@ -208,6 +212,9 @@ object PreviewUtils {
     ) {
         val scope = PreviewScope()
 
+        val prefs = Preferences.userNodeForPackage(PreviewUtils::class.java)
+        val settingsDensity: Float? = prefs.get("previewsUiScale", null)?.toFloat()
+
         singleWindowApplication(title = "Interactive Preview") {
             val focusState = remember { WindowFocusState() }
             CompositionLocalProvider(
@@ -215,22 +222,27 @@ object PreviewUtils {
                 LocalWindowFocusState provides focusState
             ) {
                 Column {
-                    Column(Modifier.padding(10.dp)) {
-                        scope.addBooleanParameter("darkTheme", true)
-                        scope.addDropDownParameter(
-                            "language",
-                            DropDownValues(0, UnencryptedSettings.Language.values().toList().map { it.name })
-                        )
-                        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)
-                                is Float -> scope.addFloatParameter(name, initial)
-                                is FloatSlider -> scope.addFloatSliderParameter(name, initial)
-                                is DropDownValues -> scope.addDropDownParameter(name, initial)
-                                else -> throw IllegalArgumentException("Type ${initial::class.simpleName} is not supported for previewing.")
+                    val density = settingsDensity ?: LocalDensity.current.density
+                    CompositionLocalProvider(LocalDensity provides Density(density)) {
+                        window.preferredSize = DensityDimension(800, 600)
+                        Column(Modifier.padding(10.dp)) {
+                            scope.addBooleanParameter("darkTheme", true)
+                            scope.addDropDownParameter(
+                                "language",
+                                DropDownValues(0, UnencryptedSettings.Language.values().toList().map { it.name })
+                            )
+                            scope.addFloatParameter("density", density)
+                            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)
+                                    is Float -> scope.addFloatParameter(name, initial)
+                                    is FloatSlider -> scope.addFloatSliderParameter(name, initial)
+                                    is DropDownValues -> scope.addDropDownParameter(name, initial)
+                                    else -> throw IllegalArgumentException("Type ${initial::class.simpleName} is not supported for previewing.")
+                                }
                             }
                         }
                     }
@@ -245,7 +257,10 @@ object PreviewUtils {
 
                     invalidate.react { return@Column }
 
-                    BriarTheme(isDarkTheme = scope.getBooleanParameter("darkTheme")) {
+                    BriarTheme(
+                        isDarkTheme = scope.getBooleanParameter("darkTheme"),
+                        density = scope.getFloatParameter("density"),
+                    ) {
                         Box(Modifier.fillMaxSize(1f)) {
                             Column(Modifier.padding(10.dp)) {
                                 content(scope)
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/utils/UiUtils.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/utils/UiUtils.kt
index 806f90fa17d326abb9ed8be5bbb3ec015ed8e830..800f9fc5ee5e87877182a4367c39d4bdcc8cbd3b 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/utils/UiUtils.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/utils/UiUtils.kt
@@ -18,7 +18,43 @@
 
 package org.briarproject.briar.desktop.utils
 
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.dp
+import java.awt.Dimension
+import java.awt.GraphicsConfiguration
+import java.awt.GraphicsEnvironment
+
 object UiUtils {
     fun getContactDisplayName(name: String, alias: String?) =
         if (alias == null) name else "$alias ($name)"
+
+    // See androidx.compose.ui.window.LayoutConfiguration
+    internal val GlobalDensity
+        get() = GraphicsEnvironment.getLocalGraphicsEnvironment()
+            .defaultScreenDevice
+            .defaultConfiguration
+            .density
+
+    // See androidx.compose.ui.window.LayoutConfiguration
+    private val GraphicsConfiguration.density: Float
+        get() = defaultTransform.scaleX.toFloat()
+
+    /**
+     * Compute an AWT Dimension for the given width and height in dp units, taking
+     * into account the LocalDensity as well as the global density as detected by the
+     * local graphics environment.
+     *
+     * On macOS hidpi devices, the global density is usually something like 2 while on Linux
+     * it is usually 1 independent of the actual density. The global density is taken into
+     * account by AWT itself, so we need to remove that factor from the equation, otherwise
+     * it will be accounted for twice resulting in windows that are bigger than expected.
+     */
+    @Composable
+    fun DensityDimension(width: Int, height: Int): Dimension {
+        with(Density(LocalDensity.current.density / GlobalDensity)) {
+            return Dimension(width.dp.roundToPx(), height.dp.roundToPx())
+        }
+    }
 }
diff --git a/briar-desktop/src/main/resources/strings/BriarDesktop.properties b/briar-desktop/src/main/resources/strings/BriarDesktop.properties
index ece143408d036ccb853e52faecb25a32f8b5d8ae..3543d39d7db4abfa2fa89ba58b72cb4c0fb9c232 100644
--- a/briar-desktop/src/main/resources/strings/BriarDesktop.properties
+++ b/briar-desktop/src/main/resources/strings/BriarDesktop.properties
@@ -323,6 +323,7 @@ settings.display.theme.dark=Dark
 settings.display.theme.light=Light
 settings.display.language.title=Language
 settings.display.language.auto=System default
+settings.display.ui_scale.title=UI Scale
 
 # Settings Connections
 settings.connections.title=Connections
diff --git a/briar-desktop/src/test/kotlin/org/briarproject/briar/desktop/SetPreviewUtilsDensity.kt b/briar-desktop/src/test/kotlin/org/briarproject/briar/desktop/SetPreviewUtilsDensity.kt
new file mode 100644
index 0000000000000000000000000000000000000000..d335a4ccf4cbeff2e29d05856aba22f4eeee53d6
--- /dev/null
+++ b/briar-desktop/src/test/kotlin/org/briarproject/briar/desktop/SetPreviewUtilsDensity.kt
@@ -0,0 +1,35 @@
+/*
+ * Briar Desktop
+ * Copyright (C) 2021-2022 The Briar Project
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package org.briarproject.briar.desktop
+
+import org.briarproject.briar.desktop.utils.PreviewUtils
+import java.util.prefs.Preferences
+
+/**
+ * This executable stores a custom density used for UI previews created using [PreviewUtils] into the user settings.
+ * On hidpi Linux devices it makes sense to set this to some value once that makes the previews appear big enough from
+ * then on.
+ * We're using a different dedicated preference node in order to keep this independent of the settings used in Briar
+ * itself.
+ */
+fun main() {
+    val prefs = Preferences.userNodeForPackage(PreviewUtils::class.java)
+    prefs.put("previewsUiScale", "2.0")
+    prefs.flush()
+}