diff --git a/scripts/fetch-lingva-languages.js b/scripts/fetch-lingva-languages.js
new file mode 100644
index 00000000..f270cabe
--- /dev/null
+++ b/scripts/fetch-lingva-languages.js
@@ -0,0 +1,18 @@
+// Fetch https://lingva.ml/api/v1/languages/{source|target}
+import fs from 'fs';
+
+fetch('https://lingva.ml/api/v1/languages/source')
+  .then((response) => response.json())
+  .then((json) => {
+    const file = './src/data/lingva-source-languages.json';
+    console.log(`Writing ${file}...`);
+    fs.writeFileSync(file, JSON.stringify(json.languages, null, '\t'), 'utf8');
+  });
+
+fetch('https://lingva.ml/api/v1/languages/target')
+  .then((response) => response.json())
+  .then((json) => {
+    const file = './src/data/lingva-target-languages.json';
+    console.log(`Writing ${file}...`);
+    fs.writeFileSync(file, JSON.stringify(json.languages, null, '\t'), 'utf8');
+  });
diff --git a/src/app.css b/src/app.css
index 62cc23de..beae550b 100644
--- a/src/app.css
+++ b/src/app.css
@@ -1007,6 +1007,12 @@ body:has(.status-deck) .media-post-link {
 .sheet header :is(h1, h2, h3) {
   margin: 0;
 }
+.sheet header.header-grid {
+  display: grid;
+  grid-template-columns: 1fr auto;
+  grid-gap: 8px;
+  align-items: center;
+}
 .sheet main {
   overflow: auto;
   overflow-x: hidden;
diff --git a/src/components/icon.jsx b/src/components/icon.jsx
index 7a5edd00..805823d9 100644
--- a/src/components/icon.jsx
+++ b/src/components/icon.jsx
@@ -63,6 +63,7 @@ const ICONS = {
   share: 'mingcute:share-2-line',
   sparkles: 'mingcute:sparkles-line',
   exit: 'mingcute:exit-line',
+  translate: 'mingcute:translate-line',
 };
 
 const modules = import.meta.glob('/node_modules/@iconify-icons/mingcute/*.js');
diff --git a/src/components/loader.css b/src/components/loader.css
index dd327cb4..c93214bb 100644
--- a/src/components/loader.css
+++ b/src/components/loader.css
@@ -6,6 +6,7 @@
   animation: appear 0.3s ease-in-out 1s both;
   vertical-align: middle;
   margin: 8px;
+  vertical-align: baseline !important;
 }
 .loader-container.abrupt {
   animation: none;
diff --git a/src/components/media-modal.jsx b/src/components/media-modal.jsx
index d79d7388..6c85b3dd 100644
--- a/src/components/media-modal.jsx
+++ b/src/components/media-modal.jsx
@@ -1,3 +1,4 @@
+import { Menu, MenuItem } from '@szhsin/react-menu';
 import { getBlurHashAverageColor } from 'fast-blurhash';
 import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
 import { useHotkeys } from 'react-hotkeys-hook';
@@ -6,6 +7,7 @@ import Icon from './icon';
 import Link from './link';
 import Media from './media';
 import Modal from './modal';
+import TranslationBlock from './translation-block';
 
 function MediaModal({
   mediaAttachments,
@@ -234,24 +236,54 @@ function MediaModal({
             }
           }}
         >
-          <div class="sheet">
-            <header>
-              <h2>Media description</h2>
-            </header>
-            <main>
-              <p
-                style={{
-                  whiteSpace: 'pre-wrap',
-                }}
-              >
-                {showMediaAlt}
-              </p>
-            </main>
-          </div>
+          <MediaAltModal alt={showMediaAlt} />
         </Modal>
       )}
     </>
   );
 }
 
+function MediaAltModal({ alt }) {
+  const [forceTranslate, setForceTranslate] = useState(false);
+  return (
+    <div class="sheet">
+      <header class="header-grid">
+        <h2>Media description</h2>
+        <div class="header-side">
+          <Menu
+            align="end"
+            menuButton={
+              <button type="button" class="plain4">
+                <Icon icon="more" alt="More" size="xl" />
+              </button>
+            }
+          >
+            <MenuItem
+              disabled={forceTranslate}
+              onClick={() => {
+                setForceTranslate(true);
+              }}
+            >
+              <Icon icon="translate" />
+              <span>Translate</span>
+            </MenuItem>
+          </Menu>
+        </div>
+      </header>
+      <main>
+        <p
+          style={{
+            whiteSpace: 'pre-wrap',
+          }}
+        >
+          {alt}
+        </p>
+        {forceTranslate && (
+          <TranslationBlock forceTranslate={forceTranslate} text={alt} />
+        )}
+      </main>
+    </div>
+  );
+}
+
 export default MediaModal;
diff --git a/src/components/status.jsx b/src/components/status.jsx
index fca28e24..04645030 100644
--- a/src/components/status.jsx
+++ b/src/components/status.jsx
@@ -20,6 +20,7 @@ import Modal from '../components/modal';
 import NameText from '../components/name-text';
 import { api } from '../utils/api';
 import enhanceContent from '../utils/enhance-content';
+import getTranslateTargetLanguage from '../utils/get-translate-target-language';
 import handleContentLinks from '../utils/handle-content-links';
 import htmlContentLength from '../utils/html-content-length';
 import niceDateTime from '../utils/nice-date-time';
@@ -35,6 +36,7 @@ import Link from './link';
 import Media from './media';
 import MenuLink from './MenuLink';
 import RelativeTime from './relative-time';
+import TranslationBlock from './translation-block';
 
 const throttle = pThrottle({
   limit: 1,
@@ -66,6 +68,7 @@ function Status({
   skeleton,
   readOnly,
   contentTextWeight,
+  enableTranslate,
 }) {
   if (skeleton) {
     return (
@@ -194,6 +197,10 @@ function Status({
     );
   }
 
+  const [forceTranslate, setForceTranslate] = useState(false);
+  const targetLanguage = getTranslateTargetLanguage(true);
+  if (!snapStates.settings.contentTranslation) enableTranslate = false;
+
   const [showEdited, setShowEdited] = useState(false);
 
   const spoilerContentRef = useRef(null);
@@ -450,6 +457,17 @@ function Status({
         <Icon icon="link" />
         <span>Copy link to post</span>
       </MenuItem>
+      {enableTranslate && (
+        <MenuItem
+          disabled={forceTranslate}
+          onClick={() => {
+            setForceTranslate(true);
+          }}
+        >
+          <Icon icon="translate" />
+          <span>Translate</span>
+        </MenuItem>
+      )}
       {navigator?.share &&
         navigator?.canShare?.({
           url,
@@ -770,6 +788,25 @@ function Status({
               }}
             />
           )}
+          {((enableTranslate &&
+            !!content.trim() &&
+            language &&
+            language !== targetLanguage) ||
+            forceTranslate) && (
+            <TranslationBlock
+              forceTranslate={forceTranslate}
+              sourceLanguage={language}
+              text={
+                (spoilerText ? `${spoilerText}\n\n` : '') +
+                getHTMLText(content) +
+                (poll?.options?.length
+                  ? `\n\nPoll:\n${poll.options
+                      .map((option) => `- ${option.title}`)
+                      .join('\n')}`
+                  : '')
+              }
+            />
+          )}
           {!spoilerText && sensitive && !!mediaAttachments.length && (
             <button
               class={`plain spoiler ${showSpoiler ? 'spoiling' : ''}`}
@@ -1480,4 +1517,16 @@ function _unfurlMastodonLink(instance, url) {
 
 const unfurlMastodonLink = throttle(_unfurlMastodonLink);
 
+const div = document.createElement('div');
+function getHTMLText(html) {
+  if (!html) return 0;
+  div.innerHTML = html
+    .replace(/<\/p>/g, '</p>\n\n')
+    .replace(/<\/li>/g, '</li>\n');
+  div.querySelectorAll('br').forEach((br) => {
+    br.replaceWith('\n');
+  });
+  return div.innerText.replace(/[\r\n]{3,}/g, '\n\n').trim();
+}
+
 export default memo(Status);
diff --git a/src/components/translation-block.css b/src/components/translation-block.css
new file mode 100644
index 00000000..4e3b0d3d
--- /dev/null
+++ b/src/components/translation-block.css
@@ -0,0 +1,86 @@
+.status-translation-block {
+  margin: 8px 0 0;
+  padding: 0;
+  font-size: 90%;
+  border-radius: 8px;
+}
+.status-translation-block summary {
+  list-style: none;
+  display: inline-block;
+}
+.status-translation-block summary::-webkit-details-marker {
+  display: none;
+}
+.status-translation-block summary button {
+  border-radius: 8px;
+  border: 1px solid var(--outline-color);
+  padding: 8px;
+  background-color: var(--bg-color);
+  font-size: 12px;
+  color: var(--text-insignificant-color);
+}
+.status-translation-block summary button:is(:hover, :focus) {
+  color: var(--text-color);
+  filter: none !important;
+}
+.status-translation-block details:not([open]) .detected {
+  display: none;
+}
+/* .status-translation-block details summary button:active, */
+.status-translation-block details[open] summary button {
+  /* color: var(--text-color); */
+  /* background-color: var(--bg-faded-color); */
+  border-bottom-left-radius: 0;
+  border-bottom-right-radius: 0;
+  border-bottom: 0;
+  margin-bottom: -1px;
+  background-image: linear-gradient(
+    to top left,
+    var(--bg-color) 50%,
+    var(--bg-faded-blur-color)
+  );
+  box-shadow: inset 0 0 0 1px var(--bg-color);
+}
+.status-translation-block .translated-block {
+  border: 1px solid var(--outline-color);
+  line-height: 1.3;
+  border-radius: 0 8px 8px 8px;
+  margin: 0;
+  padding: 8px;
+  background-color: var(--bg-color);
+  background-image: linear-gradient(
+    to bottom right,
+    var(--bg-color),
+    var(--bg-faded-blur-color)
+  );
+  white-space: pre-wrap;
+  box-shadow: inset 0 0 0 1px var(--bg-color),
+    0 1px 5px -2px var(--drop-shadow-color);
+  text-shadow: 0 1px var(--bg-color);
+}
+.status-translation-block .translated-block .translation-info * {
+  vertical-align: middle;
+}
+.status-translation-block .translated-source-select {
+  appearance: none;
+  display: inline-block;
+  margin: 0;
+  padding: 4px 8px;
+  border: 0;
+  border-radius: 8px;
+  background-color: var(--bg-faded-color);
+  color: inherit;
+  width: min-content;
+}
+.status-translation-block .translated-block output {
+  display: block;
+  margin-top: 1em;
+}
+.status-translation-block
+  .translated-block
+  output.translated-pronunciation-content {
+  opacity: 0.75;
+  padding-bottom: 1em;
+  border-top: var(--hairline-width) solid var(--bg-color);
+  border-bottom: var(--hairline-width) solid var(--outline-color);
+}
diff --git a/src/components/translation-block.jsx b/src/components/translation-block.jsx
new file mode 100644
index 00000000..aa7b4479
--- /dev/null
+++ b/src/components/translation-block.jsx
@@ -0,0 +1,154 @@
+import './translation-block.css';
+
+import { useEffect, useRef, useState } from 'preact/hooks';
+
+import sourceLanguages from '../data/lingva-source-languages';
+import getTranslateTargetLanguage from '../utils/get-translate-target-language';
+import localeCode2Text from '../utils/localeCode2Text';
+
+import Icon from './icon';
+import Loader from './loader';
+
+function TranslationBlock({
+  forceTranslate,
+  sourceLanguage,
+  onTranslate,
+  text = '',
+}) {
+  const targetLang = getTranslateTargetLanguage(true);
+  const [uiState, setUIState] = useState('default');
+  const [pronunciationContent, setPronunciationContent] = useState(null);
+  const [translatedContent, setTranslatedContent] = useState(null);
+  const [detectedLang, setDetectedLang] = useState(null);
+  const detailsRef = useRef();
+
+  const sourceLangText = sourceLanguage
+    ? localeCode2Text(sourceLanguage)
+    : null;
+  const targetLangText = localeCode2Text(targetLang);
+  const apiSourceLang = useRef('auto');
+
+  if (!onTranslate)
+    onTranslate = (source, target) => {
+      console.log('TRANSLATE', source, target, text);
+      // Using another API instance instead of lingva.ml because of this bug (slashes don't work):
+      // https://github.com/thedaviddelta/lingva-translate/issues/68
+      return fetch(
+        `https://lingva.garudalinux.org/api/v1/${source}/${target}/${encodeURIComponent(
+          text,
+        )}`,
+      )
+        .then((res) => res.json())
+        .then((res) => {
+          return {
+            provider: 'lingva',
+            content: res.translation,
+            detectedSourceLanguage: res.info.detectedSource,
+            info: res.info,
+          };
+        });
+      // return masto.v1.statuses.translate(id, {
+      //   lang: DEFAULT_LANG,
+      // });
+    };
+
+  const translate = async () => {
+    setUIState('loading');
+    const { content, detectedSourceLanguage, provider, ...props } =
+      await onTranslate(apiSourceLang.current, targetLang);
+    if (content) {
+      if (detectedSourceLanguage) {
+        const detectedLangText = localeCode2Text(detectedSourceLanguage);
+        setDetectedLang(detectedLangText);
+      }
+      if (provider === 'lingva') {
+        const pronunciation = props?.info?.pronunciation?.query;
+        if (pronunciation) {
+          setPronunciationContent(pronunciation);
+        }
+      }
+      setTranslatedContent(content);
+      setUIState('default');
+      detailsRef.current.open = true;
+      detailsRef.current.scrollIntoView({
+        behavior: 'smooth',
+        block: 'nearest',
+      });
+    } else {
+      console.error(result);
+      setUIState('error');
+    }
+  };
+
+  useEffect(() => {
+    if (forceTranslate) {
+      translate();
+    }
+  }, [forceTranslate]);
+
+  return (
+    <div class="status-translation-block">
+      <details ref={detailsRef}>
+        <summary>
+          <button
+            type="button"
+            onClick={async (e) => {
+              e.preventDefault();
+              e.stopPropagation();
+              detailsRef.current.open = !detailsRef.current.open;
+              if (uiState === 'loading') return;
+              if (!translatedContent) translate();
+            }}
+          >
+            <Icon icon="translate" />{' '}
+            <span>
+              {uiState === 'loading'
+                ? 'Translating…'
+                : sourceLanguage && !detectedLang
+                ? `Translate from ${sourceLangText}`
+                : `Translate`}
+            </span>
+          </button>
+        </summary>
+        <div class="translated-block">
+          <div class="translation-info insignificant">
+            <select
+              class="translated-source-select"
+              disabled={uiState === 'loading'}
+              onChange={(e) => {
+                apiSourceLang.current = e.target.value;
+                translate();
+              }}
+            >
+              {sourceLanguages.map((l) => (
+                <option value={l.code}>
+                  {l.code === 'auto' ? `Auto (${detectedLang ?? '…'})` : l.name}
+                </option>
+              ))}
+            </select>{' '}
+            <span>→ {targetLangText}</span>
+            <Loader abrupt hidden={uiState !== 'loading'} />
+          </div>
+          {uiState === 'error' ? (
+            <p class="ui-state">Failed to translate</p>
+          ) : (
+            !!translatedContent && (
+              <>
+                {!!pronunciationContent && (
+                  <output class="translated-pronunciation-content">
+                    {pronunciationContent}
+                  </output>
+                )}
+                <output class="translated-content" lang={targetLang}>
+                  {translatedContent}
+                </output>
+              </>
+            )
+          )}
+        </div>
+      </details>
+    </div>
+  );
+}
+
+export default TranslationBlock;
diff --git a/src/data/lingva-source-languages.json b/src/data/lingva-source-languages.json
new file mode 100644
index 00000000..bcde98d2
--- /dev/null
+++ b/src/data/lingva-source-languages.json
@@ -0,0 +1,534 @@
+[
+	{
+		"code": "auto",
+		"name": "Detect"
+	},
+	{
+		"code": "af",
+		"name": "Afrikaans"
+	},
+	{
+		"code": "sq",
+		"name": "Albanian"
+	},
+	{
+		"code": "am",
+		"name": "Amharic"
+	},
+	{
+		"code": "ar",
+		"name": "Arabic"
+	},
+	{
+		"code": "hy",
+		"name": "Armenian"
+	},
+	{
+		"code": "as",
+		"name": "Assamese"
+	},
+	{
+		"code": "ay",
+		"name": "Aymara"
+	},
+	{
+		"code": "az",
+		"name": "Azerbaijani"
+	},
+	{
+		"code": "bm",
+		"name": "Bambara"
+	},
+	{
+		"code": "eu",
+		"name": "Basque"
+	},
+	{
+		"code": "be",
+		"name": "Belarusian"
+	},
+	{
+		"code": "bn",
+		"name": "Bengali"
+	},
+	{
+		"code": "bho",
+		"name": "Bhojpuri"
+	},
+	{
+		"code": "bs",
+		"name": "Bosnian"
+	},
+	{
+		"code": "bg",
+		"name": "Bulgarian"
+	},
+	{
+		"code": "ca",
+		"name": "Catalan"
+	},
+	{
+		"code": "ceb",
+		"name": "Cebuano"
+	},
+	{
+		"code": "ny",
+		"name": "Chichewa"
+	},
+	{
+		"code": "zh",
+		"name": "Chinese"
+	},
+	{
+		"code": "co",
+		"name": "Corsican"
+	},
+	{
+		"code": "hr",
+		"name": "Croatian"
+	},
+	{
+		"code": "cs",
+		"name": "Czech"
+	},
+	{
+		"code": "da",
+		"name": "Danish"
+	},
+	{
+		"code": "dv",
+		"name": "Dhivehi"
+	},
+	{
+		"code": "doi",
+		"name": "Dogri"
+	},
+	{
+		"code": "nl",
+		"name": "Dutch"
+	},
+	{
+		"code": "en",
+		"name": "English"
+	},
+	{
+		"code": "eo",
+		"name": "Esperanto"
+	},
+	{
+		"code": "et",
+		"name": "Estonian"
+	},
+	{
+		"code": "ee",
+		"name": "Ewe"
+	},
+	{
+		"code": "tl",
+		"name": "Filipino"
+	},
+	{
+		"code": "fi",
+		"name": "Finnish"
+	},
+	{
+		"code": "fr",
+		"name": "French"
+	},
+	{
+		"code": "fy",
+		"name": "Frisian"
+	},
+	{
+		"code": "gl",
+		"name": "Galician"
+	},
+	{
+		"code": "ka",
+		"name": "Georgian"
+	},
+	{
+		"code": "de",
+		"name": "German"
+	},
+	{
+		"code": "el",
+		"name": "Greek"
+	},
+	{
+		"code": "gn",
+		"name": "Guarani"
+	},
+	{
+		"code": "gu",
+		"name": "Gujarati"
+	},
+	{
+		"code": "ht",
+		"name": "Haitian Creole"
+	},
+	{
+		"code": "ha",
+		"name": "Hausa"
+	},
+	{
+		"code": "haw",
+		"name": "Hawaiian"
+	},
+	{
+		"code": "iw",
+		"name": "Hebrew"
+	},
+	{
+		"code": "hi",
+		"name": "Hindi"
+	},
+	{
+		"code": "hmn",
+		"name": "Hmong"
+	},
+	{
+		"code": "hu",
+		"name": "Hungarian"
+	},
+	{
+		"code": "is",
+		"name": "Icelandic"
+	},
+	{
+		"code": "ig",
+		"name": "Igbo"
+	},
+	{
+		"code": "ilo",
+		"name": "Ilocano"
+	},
+	{
+		"code": "id",
+		"name": "Indonesian"
+	},
+	{
+		"code": "ga",
+		"name": "Irish"
+	},
+	{
+		"code": "it",
+		"name": "Italian"
+	},
+	{
+		"code": "ja",
+		"name": "Japanese"
+	},
+	{
+		"code": "jw",
+		"name": "Javanese"
+	},
+	{
+		"code": "kn",
+		"name": "Kannada"
+	},
+	{
+		"code": "kk",
+		"name": "Kazakh"
+	},
+	{
+		"code": "km",
+		"name": "Khmer"
+	},
+	{
+		"code": "rw",
+		"name": "Kinyarwanda"
+	},
+	{
+		"code": "gom",
+		"name": "Konkani"
+	},
+	{
+		"code": "ko",
+		"name": "Korean"
+	},
+	{
+		"code": "kri",
+		"name": "Krio"
+	},
+	{
+		"code": "ku",
+		"name": "Kurdish (Kurmanji)"
+	},
+	{
+		"code": "ckb",
+		"name": "Kurdish (Sorani)"
+	},
+	{
+		"code": "ky",
+		"name": "Kyrgyz"
+	},
+	{
+		"code": "lo",
+		"name": "Lao"
+	},
+	{
+		"code": "la",
+		"name": "Latin"
+	},
+	{
+		"code": "lv",
+		"name": "Latvian"
+	},
+	{
+		"code": "ln",
+		"name": "Lingala"
+	},
+	{
+		"code": "lt",
+		"name": "Lithuanian"
+	},
+	{
+		"code": "lg",
+		"name": "Luganda"
+	},
+	{
+		"code": "lb",
+		"name": "Luxembourgish"
+	},
+	{
+		"code": "mk",
+		"name": "Macedonian"
+	},
+	{
+		"code": "mai",
+		"name": "Maithili"
+	},
+	{
+		"code": "mg",
+		"name": "Malagasy"
+	},
+	{
+		"code": "ms",
+		"name": "Malay"
+	},
+	{
+		"code": "ml",
+		"name": "Malayalam"
+	},
+	{
+		"code": "mt",
+		"name": "Maltese"
+	},
+	{
+		"code": "mi",
+		"name": "Maori"
+	},
+	{
+		"code": "mr",
+		"name": "Marathi"
+	},
+	{
+		"code": "mni-Mtei",
+		"name": "Meiteilon (Manipuri)"
+	},
+	{
+		"code": "lus",
+		"name": "Mizo"
+	},
+	{
+		"code": "mn",
+		"name": "Mongolian"
+	},
+	{
+		"code": "my",
+		"name": "Myanmar (Burmese)"
+	},
+	{
+		"code": "ne",
+		"name": "Nepali"
+	},
+	{
+		"code": "no",
+		"name": "Norwegian"
+	},
+	{
+		"code": "or",
+		"name": "Odia (Oriya)"
+	},
+	{
+		"code": "om",
+		"name": "Oromo"
+	},
+	{
+		"code": "ps",
+		"name": "Pashto"
+	},
+	{
+		"code": "fa",
+		"name": "Persian"
+	},
+	{
+		"code": "pl",
+		"name": "Polish"
+	},
+	{
+		"code": "pt",
+		"name": "Portuguese"
+	},
+	{
+		"code": "pa",
+		"name": "Punjabi"
+	},
+	{
+		"code": "qu",
+		"name": "Quechua"
+	},
+	{
+		"code": "ro",
+		"name": "Romanian"
+	},
+	{
+		"code": "ru",
+		"name": "Russian"
+	},
+	{
+		"code": "sm",
+		"name": "Samoan"
+	},
+	{
+		"code": "sa",
+		"name": "Sanskrit"
+	},
+	{
+		"code": "gd",
+		"name": "Scots Gaelic"
+	},
+	{
+		"code": "nso",
+		"name": "Sepedi"
+	},
+	{
+		"code": "sr",
+		"name": "Serbian"
+	},
+	{
+		"code": "st",
+		"name": "Sesotho"
+	},
+	{
+		"code": "sn",
+		"name": "Shona"
+	},
+	{
+		"code": "sd",
+		"name": "Sindhi"
+	},
+	{
+		"code": "si",
+		"name": "Sinhala"
+	},
+	{
+		"code": "sk",
+		"name": "Slovak"
+	},
+	{
+		"code": "sl",
+		"name": "Slovenian"
+	},
+	{
+		"code": "so",
+		"name": "Somali"
+	},
+	{
+		"code": "es",
+		"name": "Spanish"
+	},
+	{
+		"code": "su",
+		"name": "Sundanese"
+	},
+	{
+		"code": "sw",
+		"name": "Swahili"
+	},
+	{
+		"code": "sv",
+		"name": "Swedish"
+	},
+	{
+		"code": "tg",
+		"name": "Tajik"
+	},
+	{
+		"code": "ta",
+		"name": "Tamil"
+	},
+	{
+		"code": "tt",
+		"name": "Tatar"
+	},
+	{
+		"code": "te",
+		"name": "Telugu"
+	},
+	{
+		"code": "th",
+		"name": "Thai"
+	},
+	{
+		"code": "ti",
+		"name": "Tigrinya"
+	},
+	{
+		"code": "ts",
+		"name": "Tsonga"
+	},
+	{
+		"code": "tr",
+		"name": "Turkish"
+	},
+	{
+		"code": "tk",
+		"name": "Turkmen"
+	},
+	{
+		"code": "ak",
+		"name": "Twi"
+	},
+	{
+		"code": "uk",
+		"name": "Ukrainian"
+	},
+	{
+		"code": "ur",
+		"name": "Urdu"
+	},
+	{
+		"code": "ug",
+		"name": "Uyghur"
+	},
+	{
+		"code": "uz",
+		"name": "Uzbek"
+	},
+	{
+		"code": "vi",
+		"name": "Vietnamese"
+	},
+	{
+		"code": "cy",
+		"name": "Welsh"
+	},
+	{
+		"code": "xh",
+		"name": "Xhosa"
+	},
+	{
+		"code": "yi",
+		"name": "Yiddish"
+	},
+	{
+		"code": "yo",
+		"name": "Yoruba"
+	},
+	{
+		"code": "zu",
+		"name": "Zulu"
+	}
+]
\ No newline at end of file
diff --git a/src/data/lingva-target-languages.json b/src/data/lingva-target-languages.json
new file mode 100644
index 00000000..b8c760de
--- /dev/null
+++ b/src/data/lingva-target-languages.json
@@ -0,0 +1,534 @@
+[
+	{
+		"code": "af",
+		"name": "Afrikaans"
+	},
+	{
+		"code": "sq",
+		"name": "Albanian"
+	},
+	{
+		"code": "am",
+		"name": "Amharic"
+	},
+	{
+		"code": "ar",
+		"name": "Arabic"
+	},
+	{
+		"code": "hy",
+		"name": "Armenian"
+	},
+	{
+		"code": "as",
+		"name": "Assamese"
+	},
+	{
+		"code": "ay",
+		"name": "Aymara"
+	},
+	{
+		"code": "az",
+		"name": "Azerbaijani"
+	},
+	{
+		"code": "bm",
+		"name": "Bambara"
+	},
+	{
+		"code": "eu",
+		"name": "Basque"
+	},
+	{
+		"code": "be",
+		"name": "Belarusian"
+	},
+	{
+		"code": "bn",
+		"name": "Bengali"
+	},
+	{
+		"code": "bho",
+		"name": "Bhojpuri"
+	},
+	{
+		"code": "bs",
+		"name": "Bosnian"
+	},
+	{
+		"code": "bg",
+		"name": "Bulgarian"
+	},
+	{
+		"code": "ca",
+		"name": "Catalan"
+	},
+	{
+		"code": "ceb",
+		"name": "Cebuano"
+	},
+	{
+		"code": "ny",
+		"name": "Chichewa"
+	},
+	{
+		"code": "zh",
+		"name": "Chinese"
+	},
+	{
+		"code": "zh_HANT",
+		"name": "Chinese (Traditional)"
+	},
+	{
+		"code": "co",
+		"name": "Corsican"
+	},
+	{
+		"code": "hr",
+		"name": "Croatian"
+	},
+	{
+		"code": "cs",
+		"name": "Czech"
+	},
+	{
+		"code": "da",
+		"name": "Danish"
+	},
+	{
+		"code": "dv",
+		"name": "Dhivehi"
+	},
+	{
+		"code": "doi",
+		"name": "Dogri"
+	},
+	{
+		"code": "nl",
+		"name": "Dutch"
+	},
+	{
+		"code": "en",
+		"name": "English"
+	},
+	{
+		"code": "eo",
+		"name": "Esperanto"
+	},
+	{
+		"code": "et",
+		"name": "Estonian"
+	},
+	{
+		"code": "ee",
+		"name": "Ewe"
+	},
+	{
+		"code": "tl",
+		"name": "Filipino"
+	},
+	{
+		"code": "fi",
+		"name": "Finnish"
+	},
+	{
+		"code": "fr",
+		"name": "French"
+	},
+	{
+		"code": "fy",
+		"name": "Frisian"
+	},
+	{
+		"code": "gl",
+		"name": "Galician"
+	},
+	{
+		"code": "ka",
+		"name": "Georgian"
+	},
+	{
+		"code": "de",
+		"name": "German"
+	},
+	{
+		"code": "el",
+		"name": "Greek"
+	},
+	{
+		"code": "gn",
+		"name": "Guarani"
+	},
+	{
+		"code": "gu",
+		"name": "Gujarati"
+	},
+	{
+		"code": "ht",
+		"name": "Haitian Creole"
+	},
+	{
+		"code": "ha",
+		"name": "Hausa"
+	},
+	{
+		"code": "haw",
+		"name": "Hawaiian"
+	},
+	{
+		"code": "iw",
+		"name": "Hebrew"
+	},
+	{
+		"code": "hi",
+		"name": "Hindi"
+	},
+	{
+		"code": "hmn",
+		"name": "Hmong"
+	},
+	{
+		"code": "hu",
+		"name": "Hungarian"
+	},
+	{
+		"code": "is",
+		"name": "Icelandic"
+	},
+	{
+		"code": "ig",
+		"name": "Igbo"
+	},
+	{
+		"code": "ilo",
+		"name": "Ilocano"
+	},
+	{
+		"code": "id",
+		"name": "Indonesian"
+	},
+	{
+		"code": "ga",
+		"name": "Irish"
+	},
+	{
+		"code": "it",
+		"name": "Italian"
+	},
+	{
+		"code": "ja",
+		"name": "Japanese"
+	},
+	{
+		"code": "jw",
+		"name": "Javanese"
+	},
+	{
+		"code": "kn",
+		"name": "Kannada"
+	},
+	{
+		"code": "kk",
+		"name": "Kazakh"
+	},
+	{
+		"code": "km",
+		"name": "Khmer"
+	},
+	{
+		"code": "rw",
+		"name": "Kinyarwanda"
+	},
+	{
+		"code": "gom",
+		"name": "Konkani"
+	},
+	{
+		"code": "ko",
+		"name": "Korean"
+	},
+	{
+		"code": "kri",
+		"name": "Krio"
+	},
+	{
+		"code": "ku",
+		"name": "Kurdish (Kurmanji)"
+	},
+	{
+		"code": "ckb",
+		"name": "Kurdish (Sorani)"
+	},
+	{
+		"code": "ky",
+		"name": "Kyrgyz"
+	},
+	{
+		"code": "lo",
+		"name": "Lao"
+	},
+	{
+		"code": "la",
+		"name": "Latin"
+	},
+	{
+		"code": "lv",
+		"name": "Latvian"
+	},
+	{
+		"code": "ln",
+		"name": "Lingala"
+	},
+	{
+		"code": "lt",
+		"name": "Lithuanian"
+	},
+	{
+		"code": "lg",
+		"name": "Luganda"
+	},
+	{
+		"code": "lb",
+		"name": "Luxembourgish"
+	},
+	{
+		"code": "mk",
+		"name": "Macedonian"
+	},
+	{
+		"code": "mai",
+		"name": "Maithili"
+	},
+	{
+		"code": "mg",
+		"name": "Malagasy"
+	},
+	{
+		"code": "ms",
+		"name": "Malay"
+	},
+	{
+		"code": "ml",
+		"name": "Malayalam"
+	},
+	{
+		"code": "mt",
+		"name": "Maltese"
+	},
+	{
+		"code": "mi",
+		"name": "Maori"
+	},
+	{
+		"code": "mr",
+		"name": "Marathi"
+	},
+	{
+		"code": "mni-Mtei",
+		"name": "Meiteilon (Manipuri)"
+	},
+	{
+		"code": "lus",
+		"name": "Mizo"
+	},
+	{
+		"code": "mn",
+		"name": "Mongolian"
+	},
+	{
+		"code": "my",
+		"name": "Myanmar (Burmese)"
+	},
+	{
+		"code": "ne",
+		"name": "Nepali"
+	},
+	{
+		"code": "no",
+		"name": "Norwegian"
+	},
+	{
+		"code": "or",
+		"name": "Odia (Oriya)"
+	},
+	{
+		"code": "om",
+		"name": "Oromo"
+	},
+	{
+		"code": "ps",
+		"name": "Pashto"
+	},
+	{
+		"code": "fa",
+		"name": "Persian"
+	},
+	{
+		"code": "pl",
+		"name": "Polish"
+	},
+	{
+		"code": "pt",
+		"name": "Portuguese"
+	},
+	{
+		"code": "pa",
+		"name": "Punjabi"
+	},
+	{
+		"code": "qu",
+		"name": "Quechua"
+	},
+	{
+		"code": "ro",
+		"name": "Romanian"
+	},
+	{
+		"code": "ru",
+		"name": "Russian"
+	},
+	{
+		"code": "sm",
+		"name": "Samoan"
+	},
+	{
+		"code": "sa",
+		"name": "Sanskrit"
+	},
+	{
+		"code": "gd",
+		"name": "Scots Gaelic"
+	},
+	{
+		"code": "nso",
+		"name": "Sepedi"
+	},
+	{
+		"code": "sr",
+		"name": "Serbian"
+	},
+	{
+		"code": "st",
+		"name": "Sesotho"
+	},
+	{
+		"code": "sn",
+		"name": "Shona"
+	},
+	{
+		"code": "sd",
+		"name": "Sindhi"
+	},
+	{
+		"code": "si",
+		"name": "Sinhala"
+	},
+	{
+		"code": "sk",
+		"name": "Slovak"
+	},
+	{
+		"code": "sl",
+		"name": "Slovenian"
+	},
+	{
+		"code": "so",
+		"name": "Somali"
+	},
+	{
+		"code": "es",
+		"name": "Spanish"
+	},
+	{
+		"code": "su",
+		"name": "Sundanese"
+	},
+	{
+		"code": "sw",
+		"name": "Swahili"
+	},
+	{
+		"code": "sv",
+		"name": "Swedish"
+	},
+	{
+		"code": "tg",
+		"name": "Tajik"
+	},
+	{
+		"code": "ta",
+		"name": "Tamil"
+	},
+	{
+		"code": "tt",
+		"name": "Tatar"
+	},
+	{
+		"code": "te",
+		"name": "Telugu"
+	},
+	{
+		"code": "th",
+		"name": "Thai"
+	},
+	{
+		"code": "ti",
+		"name": "Tigrinya"
+	},
+	{
+		"code": "ts",
+		"name": "Tsonga"
+	},
+	{
+		"code": "tr",
+		"name": "Turkish"
+	},
+	{
+		"code": "tk",
+		"name": "Turkmen"
+	},
+	{
+		"code": "ak",
+		"name": "Twi"
+	},
+	{
+		"code": "uk",
+		"name": "Ukrainian"
+	},
+	{
+		"code": "ur",
+		"name": "Urdu"
+	},
+	{
+		"code": "ug",
+		"name": "Uyghur"
+	},
+	{
+		"code": "uz",
+		"name": "Uzbek"
+	},
+	{
+		"code": "vi",
+		"name": "Vietnamese"
+	},
+	{
+		"code": "cy",
+		"name": "Welsh"
+	},
+	{
+		"code": "xh",
+		"name": "Xhosa"
+	},
+	{
+		"code": "yi",
+		"name": "Yiddish"
+	},
+	{
+		"code": "yo",
+		"name": "Yoruba"
+	},
+	{
+		"code": "zu",
+		"name": "Zulu"
+	}
+]
\ No newline at end of file
diff --git a/src/pages/settings.css b/src/pages/settings.css
index 8ff32ff8..9b1cf279 100644
--- a/src/pages/settings.css
+++ b/src/pages/settings.css
@@ -59,6 +59,10 @@
 #settings-container section > ul > li > div:last-child {
   text-align: right;
 }
+#settings-container section > ul > li .sub-section {
+  text-align: left !important;
+  margin-top: 8px;
+}
 #settings-container div,
 #settings-container div > * {
   vertical-align: middle;
diff --git a/src/pages/settings.jsx b/src/pages/settings.jsx
index ce12ec71..2f06cbfa 100644
--- a/src/pages/settings.jsx
+++ b/src/pages/settings.jsx
@@ -10,7 +10,10 @@ import Icon from '../components/icon';
 import Link from '../components/link';
 import NameText from '../components/name-text';
 import RelativeTime from '../components/relative-time';
+import targetLanguages from '../data/lingva-target-languages';
 import { api } from '../utils/api';
+import getTranslateTargetLanguage from '../utils/get-translate-target-language';
+import localeCode2Text from '../utils/localeCode2Text';
 import states from '../utils/states';
 import store from '../utils/store';
 
@@ -33,6 +36,11 @@ function Settings({ onClose }) {
 
   const [_, reload] = useReducer((x) => x + 1, 0);
 
+  const targetLanguage =
+    snapStates.settings.contentTranslationTargetLanguage || null;
+  const systemTargetLanguage = getTranslateTargetLanguage();
+  const systemTargetLanguageText = localeCode2Text(systemTargetLanguage);
+
   return (
     <div id="settings-container" class="sheet" tabIndex="-1">
       <main>
@@ -240,6 +248,53 @@ function Settings({ onClose }) {
                 Boosts carousel (experimental)
               </label>
             </li>
+            <li>
+              <label>
+                <input
+                  type="checkbox"
+                  checked={snapStates.settings.contentTranslation}
+                  onChange={(e) => {
+                    states.settings.contentTranslation = e.target.checked;
+                  }}
+                />{' '}
+                Post translation (experimental)
+              </label>
+              {snapStates.settings.contentTranslation && (
+                <div class="sub-section">
+                  <label>
+                    Translate to{' '}
+                    <select
+                      value={targetLanguage}
+                      onChange={(e) => {
+                        states.settings.contentTranslationTargetLanguage =
+                          e.target.value || null;
+                      }}
+                    >
+                      <option value="">
+                        System language ({systemTargetLanguageText})
+                      </option>
+                      <option disabled>──────────</option>
+                      {targetLanguages.map((lang) => (
+                        <option value={lang.code}>{lang.name}</option>
+                      ))}
+                    </select>
+                  </label>
+                  <p>
+                    <small>
+                      Note: This feature uses an external API to translate,
+                      powered by{' '}
+                      <a
+                        href="https://github.com/thedaviddelta/lingva-translate"
+                        target="_blank"
+                      >
+                        Lingva Translate
+                      </a>
+                      .
+                    </small>
+                  </p>
+                </div>
+              )}
+            </li>
           </ul>
         </section>
         <h2>Hidden features</h2>
diff --git a/src/pages/status.jsx b/src/pages/status.jsx
index 273df5f9..832e38fd 100644
--- a/src/pages/status.jsx
+++ b/src/pages/status.jsx
@@ -624,6 +624,7 @@ function StatusPage() {
                           instance={instance}
                           withinContext
                           size="l"
+                          enableTranslate
                         />
                       </InView>
                       {uiState !== 'loading' && !authenticated ? (
@@ -700,6 +701,7 @@ function StatusPage() {
                         instance={instance}
                         withinContext
                         size={thread || ancestor ? 'm' : 's'}
+                        enableTranslate
                       />
                       {/* {replies?.length > LIMIT && (
                         <div class="replies-link">
@@ -880,6 +882,7 @@ function SubComments({ hasManyStatuses, replies, instance, hasParentThread }) {
                 instance={instance}
                 withinContext
                 size="s"
+                enableTranslate
               />
               {!r.replies?.length && r.repliesCount > 0 && (
                 <div class="replies-link">
diff --git a/src/utils/get-translate-target-language.jsx b/src/utils/get-translate-target-language.jsx
new file mode 100644
index 00000000..ce837079
--- /dev/null
+++ b/src/utils/get-translate-target-language.jsx
@@ -0,0 +1,24 @@
+import { match } from '@formatjs/intl-localematcher';
+
+import translationTargetLanguages from '../data/lingva-target-languages';
+
+import states from './states';
+
+function getTranslateTargetLanguage(fromSettings = false) {
+  if (fromSettings) {
+    const { contentTranslationTargetLanguage } = states.settings;
+    if (contentTranslationTargetLanguage) {
+      return contentTranslationTargetLanguage;
+    }
+  }
+  return match(
+    [
+      new Intl.DateTimeFormat().resolvedOptions().locale,
+      ...navigator.languages,
+    ],
+    translationTargetLanguages.map((l) => l.code.replace('_', '-')), // The underscore will fail Intl.Locale inside `match`
+    'en',
+  );
+}
+
+export default getTranslateTargetLanguage;
diff --git a/src/utils/localeCode2Text.jsx b/src/utils/localeCode2Text.jsx
new file mode 100644
index 00000000..d3a1b970
--- /dev/null
+++ b/src/utils/localeCode2Text.jsx
@@ -0,0 +1,5 @@
+export default function localeCode2Text(code) {
+  return new Intl.DisplayNames(navigator.languages, {
+    type: 'language',
+  }).of(code);
+}
diff --git a/src/utils/states.js b/src/utils/states.js
index abf6d378..1355114b 100644
--- a/src/utils/states.js
+++ b/src/utils/states.js
@@ -42,6 +42,10 @@ const states = proxy({
     shortcutsColumnsMode:
       store.account.get('settings-shortcutsColumnsMode') ?? false,
     boostsCarousel: store.account.get('settings-boostsCarousel') ?? true,
+    contentTranslation:
+      store.account.get('settings-contentTranslation') ?? true,
+    contentTranslationTargetLanguage:
+      store.account.get('settings-contentTranslationTargetLanguage') || null,
   },
 });
 
@@ -63,6 +67,12 @@ subscribe(states, (v) => {
   if (path.join('.') === 'settings.shortcutsViewMode') {
     store.account.set('settings-shortcutsViewMode', value);
   }
+  if (path.join('.') === 'settings.contentTranslation') {
+    store.account.set('settings-contentTranslation', !!value);
+  }
+  if (path.join('.') === 'settings.contentTranslationTargetLanguage') {
+    store.account.set('settings-contentTranslationTargetLanguage', value);
+  }
   if (path?.[0] === 'shortcuts') {
     store.account.set('shortcuts', states.shortcuts);
   }