diff --git a/packages/frontend/src/components/MkCodeEditor.vue b/packages/frontend/src/components/MkCodeEditor.vue
index 60f16f285f..6341c454ae 100644
--- a/packages/frontend/src/components/MkCodeEditor.vue
+++ b/packages/frontend/src/components/MkCodeEditor.vue
@@ -4,30 +4,38 @@ SPDX-License-Identifier: AGPL-3.0-only
 -->
 
 <template>
-<div :class="[$style.codeEditorRoot, { [$style.focused]: focused }]">
-	<div :class="$style.codeEditorScroller">
-		<textarea
-			ref="inputEl"
-			v-model="vModel"
-			:class="[$style.textarea]"
-			:disabled="disabled"
-			:required="required"
-			:readonly="readonly"
-			autocomplete="off"
-			wrap="off"
-			spellcheck="false"
-			@focus="focused = true"
-			@blur="focused = false"
-			@keydown="onKeydown($event)"
-			@input="onInput"
-		></textarea>
-		<XCode :class="$style.codeEditorHighlighter" :codeEditor="true" :code="v" :lang="lang"/>
+<div>
+	<div :class="$style.label" @click="focus"><slot name="label"></slot></div>
+	<div :class="[$style.codeEditorRoot, { [$style.focused]: focused }]">
+		<div :class="$style.codeEditorScroller">
+			<textarea
+				ref="inputEl"
+				v-model="vModel"
+				:class="[$style.textarea]"
+				:disabled="disabled"
+				:required="required"
+				:readonly="readonly"
+				autocomplete="off"
+				wrap="off"
+				spellcheck="false"
+				@focus="focused = true"
+				@blur="focused = false"
+				@keydown="onKeydown($event)"
+				@input="onInput"
+			></textarea>
+			<XCode :class="$style.codeEditorHighlighter" :codeEditor="true" :code="v" :lang="lang"/>
+		</div>
 	</div>
+	<div :class="$style.caption"><slot name="caption"></slot></div>
+	<MkButton v-if="manualSave && changed" primary :class="$style.save" @click="updated"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
 </div>
 </template>
 
 <script lang="ts" setup>
 import { ref, watch, toRefs, shallowRef, nextTick } from 'vue';
+import { debounce } from 'throttle-debounce';
+import MkButton from '@/components/MkButton.vue';
+import { i18n } from '@/i18n.js';
 import XCode from '@/components/MkCode.core.vue';
 
 const props = withDefaults(defineProps<{
@@ -36,6 +44,8 @@ const props = withDefaults(defineProps<{
 	required?: boolean;
 	readonly?: boolean;
 	disabled?: boolean;
+	debounce?: boolean;
+	manualSave?: boolean;
 }>(), {
 	lang: 'js',
 });
@@ -54,6 +64,8 @@ const focused = ref(false);
 const changed = ref(false);
 const inputEl = shallowRef<HTMLTextAreaElement>();
 
+const focus = () => inputEl.value?.focus();
+
 const onInput = (ev) => {
 	v.value = ev.target?.value ?? v.value;
 	changed.value = true;
@@ -100,16 +112,48 @@ const updated = () => {
 	emit('update:modelValue', v.value);
 };
 
+const debouncedUpdated = debounce(1000, updated);
+
 watch(modelValue, newValue => {
 	v.value = newValue ?? '';
 });
 
-watch(v, () => {
-	updated();
+watch(v, newValue => {
+	if (!props.manualSave) {
+		if (props.debounce) {
+			debouncedUpdated();
+		} else {
+			updated();
+		}
+	}
 });
 </script>
 
 <style lang="scss" module>
+.label {
+	font-size: 0.85em;
+	padding: 0 0 8px 0;
+	user-select: none;
+
+	&:empty {
+		display: none;
+	}
+}
+
+.caption {
+	font-size: 0.85em;
+	padding: 8px 0 0 0;
+	color: var(--fgTransparentWeak);
+
+	&:empty {
+		display: none;
+	}
+}
+
+.save {
+	margin: 8px 0 0 0;
+}
+
 .codeEditorRoot {
 	min-width: 100%;
 	max-width: 100%;
@@ -117,6 +161,7 @@ watch(v, () => {
 	overflow-y: hidden;
 	box-sizing: border-box;
 	margin: 0;
+	border-radius: 6px;
 	padding: 0;
 	color: var(--fg);
 	border: solid 1px var(--panel);
@@ -157,9 +202,10 @@ watch(v, () => {
 	caret-color: rgb(225, 228, 232);
 	background-color: transparent;
 	border: 0;
+	border-radius: 6px;
 	outline: 0;
 	min-width: calc(100% - 24px);
-	height: calc(100% - 24px);
+	height: 100%;
 	padding: 12px;
 	line-height: 1.5em;
 	font-size: 1em;
diff --git a/packages/frontend/src/pages/flash/flash-edit.vue b/packages/frontend/src/pages/flash/flash-edit.vue
index 365b054f7a..67a655b677 100644
--- a/packages/frontend/src/pages/flash/flash-edit.vue
+++ b/packages/frontend/src/pages/flash/flash-edit.vue
@@ -15,9 +15,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 				<template #label>{{ i18n.ts._play.summary }}</template>
 			</MkTextarea>
 			<MkButton primary @click="selectPreset">{{ i18n.ts.selectFromPresets }}<i class="ti ti-chevron-down"></i></MkButton>
-			<MkTextarea v-model="script" code tall spellcheck="false">
+			<MkCodeEditor v-model="script" lang="is">
 				<template #label>{{ i18n.ts._play.script }}</template>
-			</MkTextarea>
+			</MkCodeEditor>
 			<div class="_buttons">
 				<MkButton primary @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
 				<MkButton @click="show"><i class="ti ti-eye"></i> {{ i18n.ts.show }}</MkButton>
@@ -40,6 +40,7 @@ import * as os from '@/os.js';
 import { i18n } from '@/i18n.js';
 import { definePageMetadata } from '@/scripts/page-metadata.js';
 import MkTextarea from '@/components/MkTextarea.vue';
+import MkCodeEditor from '@/components/MkCodeEditor.vue';
 import MkInput from '@/components/MkInput.vue';
 import MkSelect from '@/components/MkSelect.vue';
 import { useRouter } from '@/router.js';
diff --git a/packages/frontend/src/pages/registry.value.vue b/packages/frontend/src/pages/registry.value.vue
index 1b2cf9f237..8efc0e0504 100644
--- a/packages/frontend/src/pages/registry.value.vue
+++ b/packages/frontend/src/pages/registry.value.vue
@@ -26,9 +26,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 					</MkKeyValue>
 				</FormSplit>
 
-				<MkTextarea v-model="valueForEditor" tall code>
+				<MkCodeEditor v-model="valueForEditor" lang="json5">
 					<template #label>{{ i18n.ts.value }} (JSON)</template>
-				</MkTextarea>
+				</MkCodeEditor>
 
 				<MkButton primary @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
 
@@ -52,7 +52,7 @@ import { i18n } from '@/i18n.js';
 import { definePageMetadata } from '@/scripts/page-metadata.js';
 import MkButton from '@/components/MkButton.vue';
 import MkKeyValue from '@/components/MkKeyValue.vue';
-import MkTextarea from '@/components/MkTextarea.vue';
+import MkCodeEditor from '@/components/MkCodeEditor.vue';
 import FormSplit from '@/components/form/split.vue';
 import FormInfo from '@/components/MkInfo.vue';
 
diff --git a/packages/frontend/src/pages/settings/custom-css.vue b/packages/frontend/src/pages/settings/custom-css.vue
index 8e52686c12..2564562089 100644
--- a/packages/frontend/src/pages/settings/custom-css.vue
+++ b/packages/frontend/src/pages/settings/custom-css.vue
@@ -7,15 +7,15 @@ SPDX-License-Identifier: AGPL-3.0-only
 <div class="_gaps_m">
 	<FormInfo warn>{{ i18n.ts.customCssWarn }}</FormInfo>
 
-	<MkTextarea v-model="localCustomCss" manualSave tall code style="tab-size: 2;">
+	<MkCodeEditor v-model="localCustomCss" manualSave lang="css">
 		<template #label>CSS</template>
-	</MkTextarea>
+	</MkCodeEditor>
 </div>
 </template>
 
 <script lang="ts" setup>
 import { ref, watch, computed } from 'vue';
-import MkTextarea from '@/components/MkTextarea.vue';
+import MkCodeEditor from '@/components/MkCodeEditor.vue';
 import FormInfo from '@/components/MkInfo.vue';
 import * as os from '@/os.js';
 import { unisonReload } from '@/scripts/unison-reload.js';
diff --git a/packages/frontend/src/pages/settings/plugin.install.vue b/packages/frontend/src/pages/settings/plugin.install.vue
index 44472d47d9..c174ba176f 100644
--- a/packages/frontend/src/pages/settings/plugin.install.vue
+++ b/packages/frontend/src/pages/settings/plugin.install.vue
@@ -7,9 +7,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 <div class="_gaps_m">
 	<FormInfo warn>{{ i18n.ts._plugin.installWarn }}</FormInfo>
 
-	<MkTextarea v-model="code" tall code>
+	<MkCodeEditor v-model="code" lang="is">
 		<template #label>{{ i18n.ts.code }}</template>
-	</MkTextarea>
+	</MkCodeEditor>
 
 	<div>
 		<MkButton :disabled="code == null" primary inline @click="install"><i class="ti ti-check"></i> {{ i18n.ts.install }}</MkButton>
@@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import { nextTick, ref, computed } from 'vue';
-import MkTextarea from '@/components/MkTextarea.vue';
+import MkCodeEditor from '@/components/MkCodeEditor.vue';
 import MkButton from '@/components/MkButton.vue';
 import FormInfo from '@/components/MkInfo.vue';
 import * as os from '@/os.js';
diff --git a/packages/frontend/src/pages/settings/theme.install.vue b/packages/frontend/src/pages/settings/theme.install.vue
index 0f3fbad0b7..f9be5720e0 100644
--- a/packages/frontend/src/pages/settings/theme.install.vue
+++ b/packages/frontend/src/pages/settings/theme.install.vue
@@ -5,9 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <template>
 <div class="_gaps_m">
-	<MkTextarea v-model="installThemeCode" code>
+	<MkCodeEditor v-model="installThemeCode" lang="json5">
 		<template #label>{{ i18n.ts._theme.code }}</template>
-	</MkTextarea>
+	</MkCodeEditor>
 
 	<div class="_buttons">
 		<MkButton :disabled="installThemeCode == null" inline @click="() => previewTheme(installThemeCode)"><i class="ti ti-eye"></i> {{ i18n.ts.preview }}</MkButton>
@@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import { ref, computed } from 'vue';
-import MkTextarea from '@/components/MkTextarea.vue';
+import MkCodeEditor from '@/components/MkCodeEditor.vue';
 import MkButton from '@/components/MkButton.vue';
 import { parseThemeCode, previewTheme, installTheme } from '@/scripts/install-theme.js';
 import * as os from '@/os.js';
diff --git a/packages/frontend/src/pages/theme-editor.vue b/packages/frontend/src/pages/theme-editor.vue
index c4138e23de..eee3e49db1 100644
--- a/packages/frontend/src/pages/theme-editor.vue
+++ b/packages/frontend/src/pages/theme-editor.vue
@@ -51,9 +51,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 				<template #label>{{ i18n.ts.editCode }}</template>
 
 				<div class="_gaps_m">
-					<MkTextarea v-model="themeCode" tall code>
+					<MkCodeEditor v-model="themeCode" lang="json5">
 						<template #label>{{ i18n.ts._theme.code }}</template>
-					</MkTextarea>
+					</MkCodeEditor>
 					<MkButton primary @click="applyThemeCode">{{ i18n.ts.apply }}</MkButton>
 				</div>
 			</MkFolder>
@@ -80,6 +80,7 @@ import { v4 as uuid } from 'uuid';
 import JSON5 from 'json5';
 
 import MkButton from '@/components/MkButton.vue';
+import MkCodeEditor from '@/components/MkCodeEditor.vue';
 import MkTextarea from '@/components/MkTextarea.vue';
 import MkFolder from '@/components/MkFolder.vue';