diff --git a/src/Content/Image.php b/src/Content/Image.php
new file mode 100644
index 0000000000..cc2fd5122c
--- /dev/null
+++ b/src/Content/Image.php
@@ -0,0 +1,154 @@
+<?php
+/**
+ * @copyright Copyright (C) 2010-2023, the Friendica project
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * 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/>.
+ *
+ */
+
+namespace Friendica\Content;
+
+use Friendica\Content\Image\Collection\MasonryImageRow;
+use Friendica\Content\Image\Entity\MasonryImage;
+use Friendica\Content\Post\Collection\PostMedias;
+use Friendica\Core\Renderer;
+
+class Image
+{
+	public static function getBodyAttachHtml(PostMedias $PostMediaImages): string
+	{
+		$media = '';
+
+		if ($PostMediaImages->haveDimensions()) {
+			if (count($PostMediaImages) > 1) {
+				$media = self::getHorizontalMasonryHtml($PostMediaImages);
+			} elseif (count($PostMediaImages) == 1) {
+				$media = Renderer::replaceMacros(Renderer::getMarkupTemplate('content/image/single_with_height_allocation.tpl'), [
+					'$image' => $PostMediaImages[0],
+					'$allocated_height' => $PostMediaImages[0]->getAllocatedHeight(),
+					'$allocated_max_width' => ($PostMediaImages[0]->previewWidth ?? $PostMediaImages[0]->width) . 'px',
+				]);
+			}
+		} else {
+			if (count($PostMediaImages) > 1) {
+				$media = self::getImageGridHtml($PostMediaImages);
+			} elseif (count($PostMediaImages) == 1) {
+				$media = Renderer::replaceMacros(Renderer::getMarkupTemplate('content/image/single.tpl'), [
+					'$image' => $PostMediaImages[0],
+				]);
+			}
+		}
+
+		return $media;
+	}
+
+	/**
+	 * @param PostMedias $images
+	 * @return string
+	 * @throws \Friendica\Network\HTTPException\ServiceUnavailableException
+	 */
+	private static function getImageGridHtml(PostMedias $images): string
+	{
+		// Image for first column (fc) and second column (sc)
+		$images_fc = [];
+		$images_sc = [];
+
+		for ($i = 0; $i < count($images); $i++) {
+			($i % 2 == 0) ? ($images_fc[] = $images[$i]) : ($images_sc[] = $images[$i]);
+		}
+
+		return Renderer::replaceMacros(Renderer::getMarkupTemplate('content/image/grid.tpl'), [
+			'columns' => [
+				'fc' => $images_fc,
+				'sc' => $images_sc,
+			],
+		]);
+	}
+
+	/**
+	 * Creates a horizontally masoned gallery with a fixed maximum number of pictures per row.
+	 *
+	 * For each row, we calculate how much of the total width each picture will take depending on their aspect ratio
+	 * and how much relative height it needs to accomodate all pictures next to each other with their height normalized.
+	 *
+	 * @param array $images
+	 * @return string
+	 * @throws \Friendica\Network\HTTPException\ServiceUnavailableException
+	 */
+	private static function getHorizontalMasonryHtml(PostMedias $images): string
+	{
+		static $column_size = 2;
+
+		$rows = array_map(
+			function (PostMedias $PostMediaImages) {
+				if ($singleImageInRow = count($PostMediaImages) == 1) {
+					$PostMediaImages[] = $PostMediaImages[0];
+				}
+
+				$widths = [];
+				$heights = [];
+				foreach ($PostMediaImages as $PostMediaImage) {
+					if ($PostMediaImage->width && $PostMediaImage->height) {
+						$widths[] = $PostMediaImage->width;
+						$heights[] = $PostMediaImage->height;
+					} else {
+						$widths[] = $PostMediaImage->previewWidth;
+						$heights[] = $PostMediaImage->previewHeight;
+					}
+				}
+
+				$maxHeight = max($heights);
+
+				// Corrected width preserving aspect ratio when all images on a row are the same height
+				$correctedWidths = [];
+				foreach ($widths as $i => $width) {
+					$correctedWidths[] = $width * $maxHeight / $heights[$i];
+				}
+
+				$totalWidth = array_sum($correctedWidths);
+
+				$row_images2 = [];
+
+				if ($singleImageInRow) {
+					unset($PostMediaImages[1]);
+				}
+
+				foreach ($PostMediaImages as $i => $PostMediaImage) {
+					$row_images2[] = new MasonryImage(
+						$PostMediaImage->uriId,
+						$PostMediaImage->url,
+						$PostMediaImage->preview,
+						$PostMediaImage->description ?? '',
+						100 * $correctedWidths[$i] / $totalWidth,
+						100 * $maxHeight / $correctedWidths[$i]
+					);
+				}
+
+				// This magic value will stay constant for each image of any given row and is ultimately
+				// used to determine the height of the row container relative to the available width.
+				$commonHeightRatio = 100 * $correctedWidths[0] / $totalWidth / ($widths[0] / $heights[0]);
+
+				return new MasonryImageRow($row_images2, count($row_images2), $commonHeightRatio);
+			},
+			$images->chunk($column_size)
+		);
+
+		return Renderer::replaceMacros(Renderer::getMarkupTemplate('content/image/horizontal_masonry.tpl'), [
+			'$rows' => $rows,
+			'$column_size' => $column_size,
+		]);
+	}
+}
diff --git a/src/Content/Image/Collection/MasonryImageRow.php b/src/Content/Image/Collection/MasonryImageRow.php
new file mode 100644
index 0000000000..ff507786f5
--- /dev/null
+++ b/src/Content/Image/Collection/MasonryImageRow.php
@@ -0,0 +1,57 @@
+<?php
+/**
+ * @copyright Copyright (C) 2010-2023, the Friendica project
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * 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/>.
+ *
+ */
+
+namespace Friendica\Content\Image\Collection;
+
+use Friendica\Content\Image\Entity;
+use Friendica\BaseCollection;
+use Friendica\Content\Image\Entity\MasonryImage;
+
+class MasonryImageRow extends BaseCollection
+{
+	/** @var ?float */
+	protected $heightRatio;
+
+	/**
+	 * @param MasonryImage[] $entities
+	 * @param int|null       $totalCount
+	 * @param float|null     $heightRatio
+	 */
+	public function __construct(array $entities = [], int $totalCount = null, float $heightRatio = null)
+	{
+		parent::__construct($entities, $totalCount);
+
+		$this->heightRatio = $heightRatio;
+	}
+
+	/**
+	 * @return Entity\MasonryImage
+	 */
+	public function current(): Entity\MasonryImage
+	{
+		return parent::current();
+	}
+
+	public function getHeightRatio(): ?float
+	{
+		return $this->heightRatio;
+	}
+}
diff --git a/src/Content/Image/Entity/MasonryImage.php b/src/Content/Image/Entity/MasonryImage.php
new file mode 100644
index 0000000000..e85688ea25
--- /dev/null
+++ b/src/Content/Image/Entity/MasonryImage.php
@@ -0,0 +1,60 @@
+<?php
+/**
+ * @copyright Copyright (C) 2010-2023, the Friendica project
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * 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/>.
+ *
+ */
+
+namespace Friendica\Content\Image\Entity;
+
+use Friendica\BaseEntity;
+use Psr\Http\Message\UriInterface;
+
+/**
+ * @property-read int $uriId
+ * @property-read UriInterface $url
+ * @property-read ?UriInterface $preview
+ * @property-read string $description
+ * @property-read float $heightRatio
+ * @property-read float $widthRatio
+ * @see \Friendica\Content\Image::getHorizontalMasonryHtml()
+ */
+class MasonryImage extends BaseEntity
+{
+	/** @var int */
+	protected $uriId;
+	/** @var UriInterface */
+	protected $url;
+	/** @var ?UriInterface */
+	protected $preview;
+	/** @var string */
+	protected $description;
+	/** @var float Ratio of the width of the image relative to the total width of the images on the row */
+	protected $widthRatio;
+	/** @var float Ratio of the height of the image relative to its width for height allocation */
+	protected $heightRatio;
+
+	public function __construct(int $uriId, UriInterface $url, ?UriInterface $preview, string $description, float $widthRatio, float $heightRatio)
+	{
+		$this->url         = $url;
+		$this->uriId       = $uriId;
+		$this->preview     = $preview;
+		$this->description = $description;
+		$this->widthRatio  = $widthRatio;
+		$this->heightRatio = $heightRatio;
+	}
+}
diff --git a/src/Content/Post/Collection/PostMedias.php b/src/Content/Post/Collection/PostMedias.php
index 5e75d908a7..9f7d10d0ce 100644
--- a/src/Content/Post/Collection/PostMedias.php
+++ b/src/Content/Post/Collection/PostMedias.php
@@ -42,4 +42,16 @@ class PostMedias extends BaseCollection
 	{
 		return parent::current();
 	}
+
+	/**
+	 * Determine whether all the collection's item have at least one set of dimensions provided
+	 *
+	 * @return bool
+	 */
+	public function haveDimensions(): bool
+	{
+		return array_reduce($this->getArrayCopy(), function (bool $carry, Entity\PostMedia $item) {
+			return $carry && $item->hasDimensions();
+		}, true);
+	}
 }
diff --git a/src/Content/Post/Entity/PostMedia.php b/src/Content/Post/Entity/PostMedia.php
index e03246315c..8220624198 100644
--- a/src/Content/Post/Entity/PostMedia.php
+++ b/src/Content/Post/Entity/PostMedia.php
@@ -188,6 +188,30 @@ class PostMedia extends BaseEntity
 
 	}
 
+	/**
+	 * Computes the allocated height value used in the content/image/single_with_height_allocation.tpl template
+	 *
+	 * Either base or preview dimensions need to be set at runtime.
+	 *
+	 * @return string
+	 */
+	public function getAllocatedHeight(): string
+	{
+		if (!$this->hasDimensions()) {
+			throw new \RangeException('Either width and height or previewWidth and previewHeight must be defined to use this method.');
+		}
+
+		if ($this->width && $this->height) {
+			$width  = $this->width;
+			$height = $this->height;
+		} else {
+			$width  = $this->previewWidth;
+			$height = $this->previewHeight;
+		}
+
+		return (100 * $height / $width) . '%';
+	}
+
 	/**
 	 * Return a new PostMedia entity with a different preview URI and an optional proxy size name.
 	 * The new entity preview's width and height are rescaled according to the provided size.
@@ -263,4 +287,14 @@ class PostMedia extends BaseEntity
 			$this->id,
 		);
 	}
+
+	/**
+	 * Checks the media has at least one full set of dimensions, needed for the height allocation feature
+	 *
+	 * @return bool
+	 */
+	public function hasDimensions(): bool
+	{
+		return $this->width && $this->height || $this->previewWidth && $this->previewHeight;
+	}
 }
diff --git a/src/Model/Item.php b/src/Model/Item.php
index 53183f1d2f..457d76ec78 100644
--- a/src/Model/Item.php
+++ b/src/Model/Item.php
@@ -22,6 +22,7 @@
 namespace Friendica\Model;
 
 use Friendica\Contact\LocalRelationship\Entity\LocalRelationship;
+use Friendica\Content\Image;
 use Friendica\Content\Post\Collection\PostMedias;
 use Friendica\Content\Post\Entity\PostMedia;
 use Friendica\Content\Text\BBCode;
@@ -3241,7 +3242,7 @@ class Item
 		}
 
 		if (!empty($sharedSplitAttachments)) {
-			$s = self::addGallery($s, $sharedSplitAttachments['visual'], $item['uri-id']);
+			$s = self::addGallery($s, $sharedSplitAttachments['visual']);
 			$s = self::addVisualAttachments($sharedSplitAttachments['visual'], $shared_item, $s, true);
 			$s = self::addLinkAttachment($shared_uri_id ?: $item['uri-id'], $sharedSplitAttachments, $body, $s, true, $quote_shared_links);
 			$s = self::addNonVisualAttachments($sharedSplitAttachments['additional'], $item, $s, true);
@@ -3254,7 +3255,7 @@ class Item
 			$s = substr($s, 0, $pos);
 		}
 
-		$s = self::addGallery($s, $itemSplitAttachments['visual'], $item['uri-id']);
+		$s = self::addGallery($s, $itemSplitAttachments['visual']);
 		$s = self::addVisualAttachments($itemSplitAttachments['visual'], $item, $s, false);
 		$s = self::addLinkAttachment($item['uri-id'], $itemSplitAttachments, $body, $s, false, $shared_links);
 		$s = self::addNonVisualAttachments($itemSplitAttachments['additional'], $item, $s, false);
@@ -3285,45 +3286,32 @@ class Item
 		return $hook_data['html'];
 	}
 
-	/**
-	 * @param PostMedias $images
-	 * @return string
-	 * @throws \Friendica\Network\HTTPException\ServiceUnavailableException
-	 */
-	private static function makeImageGrid(PostMedias $images): string
-	{
-		// Image for first column (fc) and second column (sc)
-		$images_fc = [];
-		$images_sc = [];
-
-		for ($i = 0; $i < count($images); $i++) {
-			($i % 2 == 0) ? ($images_fc[] = $images[$i]) : ($images_sc[] = $images[$i]);
-		}
-
-		return Renderer::replaceMacros(Renderer::getMarkupTemplate('content/image_grid.tpl'), [
-			'columns' => [
-				'fc' => $images_fc,
-				'sc' => $images_sc,
-			],
-		]);
-	}
-
 	/**
 	 * Modify links to pictures to links for the "Fancybox" gallery
 	 *
 	 * @param string     $s
 	 * @param PostMedias $PostMedias
-	 * @param int        $uri_id
 	 * @return string
 	 */
-	private static function addGallery(string $s, PostMedias $PostMedias, int $uri_id): string
+	private static function addGallery(string $s, PostMedias $PostMedias): string
 	{
 		foreach ($PostMedias as $PostMedia) {
 			if (!$PostMedia->preview || ($PostMedia->type !== Post\Media::IMAGE)) {
 				continue;
 			}
 
-			$s = str_replace('<a href="' . $PostMedia->url . '"', '<a data-fancybox="' . $uri_id . '" href="' . $PostMedia->url . '"', $s);
+			if ($PostMedia->hasDimensions()) {
+				$pattern = '#<a href="' . preg_quote($PostMedia->url) . '">(.*?)"></a>#';
+
+				$s = preg_replace_callback($pattern, function () use ($PostMedia) {
+					return Renderer::replaceMacros(Renderer::getMarkupTemplate('content/image/single_with_height_allocation.tpl'), [
+						'$image' => $PostMedia,
+						'$allocated_height' => $PostMedia->getAllocatedHeight(),
+					]);
+				}, $s);
+			} else {
+				$s = str_replace('<a href="' . $PostMedia->url . '"', '<a data-fancybox="uri-id-' . $PostMedia->uriId . '" href="' . $PostMedia->url . '"', $s);
+			}
 		}
 
 		return $s;
@@ -3494,14 +3482,7 @@ class Item
 			}
 		}
 
-		$media = '';
-		if (count($images) > 1) {
-			$media = self::makeImageGrid($images);
-		} elseif (count($images) == 1) {
-			$media = Renderer::replaceMacros(Renderer::getMarkupTemplate('content/image.tpl'), [
-				'$image' => $images[0],
-			]);
-		}
+		$media = Image::getBodyAttachHtml($images);
 
 		// On Diaspora posts the attached pictures are leading
 		if ($item['network'] == Protocol::DIASPORA) {
diff --git a/view/global.css b/view/global.css
index 714bb55dbd..ecab5a1c15 100644
--- a/view/global.css
+++ b/view/global.css
@@ -706,6 +706,39 @@ audio {
  * Image grid settings END
  **/
 
+/* This helps allocating space for image before they are loaded, preventing content shifting once they are.
+ * Inspired by https://www.smashingmagazine.com/2016/08/ways-to-reduce-content-shifting-on-page-load/
+ * Please note: The space is effectively allocated using padding-bottom using the image ratio as a value.
+ * This ratio is never known in advance so no value is set in the stylesheet.
+ */
+figure.img-allocated-height {
+	position: relative;
+	background: center / auto rgba(0, 0, 0, 0.05) url(/images/icons/image.png) no-repeat;
+	margin: 0;
+}
+figure.img-allocated-height img{
+	position: absolute;
+	top: 0;
+	right: 0;
+	bottom: 0;
+	left: 0;
+	width: 100%;
+}
+
+/**
+ * Horizontal masonry settings START
+ **/
+.masonry-row {
+	display: -ms-flexbox; /* IE10 */
+	display: flex;
+	/* Both the following values should be the same to ensure consistent margins between images in the grid */
+	column-gap: 5px;
+	margin-top: 5px;
+}
+/**
+ * Horizontal masonry settings AND
+ **/
+
 #contactblock .icon {
 	width: 48px;
 	height: 48px;
diff --git a/view/templates/content/image_grid.tpl b/view/templates/content/image/grid.tpl
similarity index 62%
rename from view/templates/content/image_grid.tpl
rename to view/templates/content/image/grid.tpl
index 95e49ee3e1..091e69e8e2 100644
--- a/view/templates/content/image_grid.tpl
+++ b/view/templates/content/image/grid.tpl
@@ -1,12 +1,12 @@
 <div class="imagegrid-row">
 	<div class="imagegrid-column">
 		{{foreach $columns.fc as $img}}
-				{{include file="content/image.tpl" image=$img}}
+				{{include file="content/image/single.tpl" image=$img}}
 		{{/foreach}}
 	</div>
 	<div class="imagegrid-column">
 		{{foreach $columns.sc as $img}}
-				{{include file="content/image.tpl" image=$img}}
+				{{include file="content/image/single.tpl" image=$img}}
 		{{/foreach}}
 	</div>
-</div>
\ No newline at end of file
+</div>
diff --git a/view/templates/content/image/horizontal_masonry.tpl b/view/templates/content/image/horizontal_masonry.tpl
new file mode 100644
index 0000000000..223a9c4a43
--- /dev/null
+++ b/view/templates/content/image/horizontal_masonry.tpl
@@ -0,0 +1,12 @@
+{{foreach $rows as $images}}
+	<div class="masonry-row" style="height: {{$images->getHeightRatio()}}%">
+        {{foreach $images as $image}}
+            {{* The absolute pixel value in the calc() should be mirrored from the .imagegrid-row column-gap value *}}
+            {{include file="content/image/single_with_height_allocation.tpl"
+	            image=$image
+	            allocated_height="calc(`$image->heightRatio * $image->widthRatio / 100`% - 5px / `$column_size`)"
+	            allocated_width="`$image->widthRatio`%"
+            }}
+        {{/foreach}}
+	</div>
+{{/foreach}}
diff --git a/view/templates/content/image.tpl b/view/templates/content/image/single.tpl
similarity index 100%
rename from view/templates/content/image.tpl
rename to view/templates/content/image/single.tpl
diff --git a/view/templates/content/image/single_with_height_allocation.tpl b/view/templates/content/image/single_with_height_allocation.tpl
new file mode 100644
index 0000000000..1d70194bef
--- /dev/null
+++ b/view/templates/content/image/single_with_height_allocation.tpl
@@ -0,0 +1,20 @@
+{{* The padding-top height allocation trick only works if the <figure> fills its parent's width completely or with flex. 🤷‍♂️
+	As a result, we need to add a wrapping element for non-flex (non-image grid) environments, mostly single-image cases.
+ *}}
+{{if $allocated_max_width}}
+<div style="max-width: {{$allocated_max_width|default:"auto"}};">
+{{/if}}
+
+<figure class="img-allocated-height" style="width: {{$allocated_width|default:"auto"}}; padding-bottom: {{$allocated_height}}">
+    {{if $image->preview}}
+		<a data-fancybox="uri-id-{{$image->uriId}}" href="{{$image->url}}">
+			<img src="{{$image->preview}}" alt="{{$image->description}}" title="{{$image->description}}" loading="lazy">
+		</a>
+    {{else}}
+		<img src="{{$image->url}}" alt="{{$image->description}}" title="{{$image->description}}" loading="lazy">
+    {{/if}}
+</figure>
+
+{{if $allocated_max_width}}
+</div>
+{{/if}}
diff --git a/view/theme/frio/scheme/black.css b/view/theme/frio/scheme/black.css
index debf9d99b3..561f708a81 100644
--- a/view/theme/frio/scheme/black.css
+++ b/view/theme/frio/scheme/black.css
@@ -394,3 +394,7 @@ input[type="text"].tt-input {
 textarea#profile-jot-text:focus + #preview_profile-jot-text, textarea.comment-edit-text:focus + .comment-edit-form .preview {
 	border-color: $link_color;
 }
+
+figure.img-allocated-height {
+	background-color: rgba(255, 255, 255, 0.15);
+}
diff --git a/view/theme/frio/scheme/dark.css b/view/theme/frio/scheme/dark.css
index add36fff10..434681c558 100644
--- a/view/theme/frio/scheme/dark.css
+++ b/view/theme/frio/scheme/dark.css
@@ -354,3 +354,7 @@ input[type="text"].tt-input {
 textarea#profile-jot-text:focus + #preview_profile-jot-text, textarea.comment-edit-text:focus + .comment-edit-form .preview {
 	border-color: $link_color;
 }
+
+figure.img-allocated-height {
+	background-color: rgba(255, 255, 255, 0.05);
+}