diff --git a/Cargo.toml b/Cargo.toml
index b3556ed..f5d225f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -41,6 +41,7 @@ human-panic = "1.2.0"
 inventory = "0.3"
 itertools = "0.11.0"
 lazy_static = "1.4.0"
+lender = "0.2.1"
 libc = "0.2.148"
 log = "0.4.20"
 nonempty = { version = "0.8.1", features = ["serialize"] }
@@ -61,6 +62,14 @@ crossterm = { version = "0.27.0", features = ["serde", "event-stream"] }
 ratatui = { version = "0.24.0", features = ["serde", "macros"] }
 strip-ansi-escapes = "0.2.0"
 tui-textarea = "0.3.0"
+unicode-width = "0.1"
+
+[patch.crates-io]
+# we need these changes:
+# * 'make widgets::reflow public' https://github.com/ratatui-org/ratatui/pull/607
+# * 'define struct WrappedLine instead of anonymous tuple' https://github.com/ratatui-org/ratatui/pull/608
+ratatui = { git = "https://github.com/progval/ratatui.git", rev = "54a3923b9d5f37da848dbc32a2ffb4eeb4f47490", features = ["serde", "macros"] }
+#ratatui = { path = "../ratatui", features = ["serde", "macros"] }
 
 [dev-dependencies]
 pretty_assertions = "1.4.0"
diff --git a/src/buffers/log.rs b/src/buffers/log.rs
index 10ca35d..63ab0b3 100644
--- a/src/buffers/log.rs
+++ b/src/buffers/log.rs
@@ -39,20 +39,23 @@ impl Buffer for LogBuffer {
     "ratatrix".to_owned()
   }
 
-  fn content(&self) -> Text {
+  fn content(&self) -> Vec<Text> {
+    use ansi_to_tui::IntoText;
     let lines = self
       .lines
       .read()
       .expect("LogBuffer could not get log's RwLock as it is poisoned");
     let (slice1, slice2) = lines.as_slices();
-    let text = if slice1.is_empty() {
-      slice2.join("\n")
-    } else if slice2.is_empty() {
-      slice1.join("\n")
-    } else {
-      format!("{}\n{}", slice1.join("\n"), slice2.join("\n"))
-    };
-    use ansi_to_tui::IntoText;
-    text.clone().into_text().unwrap_or_else(|_| text.into())
+    slice1
+      .into_iter()
+      .chain(slice2.into_iter())
+      .cloned()
+      .map(|line| {
+        line.into_text().unwrap_or_else(|e| {
+          tracing::error!("Could not convert line from ANSI codes to ratatui: {}", e);
+          Text::raw(line)
+        })
+      })
+      .collect()
   }
 }
diff --git a/src/buffers/mod.rs b/src/buffers/mod.rs
index a0d5579..91d2e7a 100644
--- a/src/buffers/mod.rs
+++ b/src/buffers/mod.rs
@@ -27,7 +27,7 @@ pub trait Buffer: Send {
   /// A short human-readable name for the room, eg. to show in compact buflist
   fn short_name(&self) -> String;
   async fn poll_updates(&mut self) {}
-  fn content(&self) -> ratatui::text::Text;
+  fn content(&self) -> Vec<ratatui::text::Text>; // TODO: make this lazy, only the last few are used
 }
 
 pub struct Buffers {
diff --git a/src/buffers/room.rs b/src/buffers/room.rs
index 5487b9c..52a66e1 100644
--- a/src/buffers/room.rs
+++ b/src/buffers/room.rs
@@ -26,6 +26,7 @@ use matrix_sdk::ruma::OwnedRoomId;
 use matrix_sdk::Client;
 use matrix_sdk::Room;
 use matrix_sdk_ui::timeline::{RoomExt, Timeline, TimelineItem};
+use ratatui::text::Text;
 use smallvec::SmallVec;
 use tokio::pin;
 
@@ -45,7 +46,7 @@ impl SingleClientRoomBuffer {
         .stream
         .next()
         .await
-        .map(|change| format!("New item: {:#?}", change)),
+        .map(|change| format!("New items: {:#?}", change)),
     );
   }
 }
@@ -123,7 +124,7 @@ impl Buffer for RoomBuffer {
     .await;
   }
 
-  fn content(&self) -> ratatui::text::Text {
+  fn content(&self) -> Vec<Text> {
     // TODO: merge buffers, etc.
     self
       .buffers
@@ -131,7 +132,7 @@ impl Buffer for RoomBuffer {
       .unwrap_or_else(|| panic!("No sub-buffer for {}", self.room_id))
       .items
       .iter()
-      .join("\n")
-      .into()
+      .map(|line|Text::raw(line))
+      .collect()
   }
 }
diff --git a/src/components/backlog.rs b/src/components/backlog.rs
index ae1b0f4..1ffdfda 100644
--- a/src/components/backlog.rs
+++ b/src/components/backlog.rs
@@ -18,6 +18,8 @@ use super::Component;
 use color_eyre::eyre::{Result, WrapErr};
 use ratatui::{prelude::*, widgets::*};
 
+use crate::widgets::{BottomAlignedParagraph, OverlappableWidget};
+
 #[derive(Default)]
 pub struct Backlog {}
 
@@ -28,12 +30,18 @@ impl Component for Backlog {
     area: Rect,
     buffers: &crate::buffers::Buffers,
   ) -> Result<()> {
-    frame.render_widget(
-      Paragraph::new(buffers.active_buffer().content())
-        .block(Block::new().borders(Borders::ALL))
-        .wrap(Wrap { trim: true }),
-      area,
-    );
+    let block = Block::new().borders(Borders::ALL);
+    let mut text_area = block.inner(area);
+    block.render(area, frame.buffer_mut());
+
+    let mut items = buffers.active_buffer().content();
+    items.reverse();
+    for item in items {
+      let widget = BottomAlignedParagraph::new(item);
+      let height = widget.render_overlap(text_area, frame.buffer_mut());
+      assert!(area.height >= height, "{:?} {}", area, height);
+      text_area.height -= height; // Remove lines at the bottom used by this paragraph
+    }
     Ok(())
   }
 }
diff --git a/src/main.rs b/src/main.rs
index 6ed00a6..2ed0bc4 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -14,6 +14,7 @@ pub mod mode;
 pub mod plugins;
 pub mod tui;
 pub mod utils;
+pub mod widgets;
 
 use clap::Parser;
 use cli::Cli;
diff --git a/src/widgets/bottom_aligned_paragraph.rs b/src/widgets/bottom_aligned_paragraph.rs
new file mode 100644
index 0000000..85f957f
--- /dev/null
+++ b/src/widgets/bottom_aligned_paragraph.rs
@@ -0,0 +1,107 @@
+/*
+ * Copyright (C) 2023  Valentin Lorentz
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License version 3,
+ * as published by the Free Software Foundation.
+ *
+ * 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/>.
+ */
+
+use lender::{Lender, Lending};
+use ratatui::prelude::*;
+use ratatui::text::StyledGrapheme;
+use ratatui::widgets::reflow::WordWrapper;
+use unicode_width::UnicodeWidthStr;
+
+use super::OverlappableWidget;
+
+/// A variant of [`Paragraph`](ratatui::widgets::Paragraph) that implements [`BottomAlignedWidget`]
+/// and always wraps
+pub struct BottomAlignedParagraph<'a> {
+  text: Text<'a>,
+  scroll: u16,
+}
+
+impl<'a> BottomAlignedParagraph<'a> {
+  pub fn new<T>(text: T) -> BottomAlignedParagraph<'a>
+  where
+    T: Into<Text<'a>>,
+  {
+    BottomAlignedParagraph {
+      text: text.into(),
+      scroll: 0,
+    }
+  }
+
+  /// How many lines should be skipped at the beginning
+  ///
+  /// This is like [`Paragraph::scroll`](ratatui::widgets::Paragraph::scroll), but it's only vertical.
+  pub fn scroll(mut self, offset: u16) -> BottomAlignedParagraph<'a> {
+    self.scroll = offset;
+    self
+  }
+}
+
+impl<'a> OverlappableWidget for BottomAlignedParagraph<'a> {
+  fn render_overlap(self, area: Rect, buf: &mut Buffer) -> u16 {
+    // Inspired by https://github.com/ratatui-org/ratatui/blob/9f371000968044e09545d66068c4ed4ea4b35d8a/src/widgets/paragraph.rs#L214-L275
+    let trim = false;
+    let style = Style::default();
+    let lines: Vec<_> = WordWrapper::new(
+      self.text.lines.iter().map(|line| {
+        (
+          line
+            .spans
+            .iter()
+            .flat_map(|span| span.styled_graphemes(style)),
+          Alignment::Left,
+        )
+      }),
+      area.width,
+      trim,
+    )
+    .skip(self.scroll as usize)
+    .map_into_iter(|line: ratatui::widgets::reflow::WrappedLine| line.line.to_vec())
+    .collect();
+    let text_area = area; // Borders not supported by BottomAlignedParagraph (yet?)
+    let text_area_height = text_area.height as usize;
+    let lines = if lines.len() > text_area_height {
+      // Overflow; keep only the last lines
+      &lines[(lines.len() - text_area_height)..]
+    } else {
+      &lines[..]
+    };
+
+    assert!(lines.len() <= text_area_height);
+
+    for (y, line) in lines.into_iter().rev().enumerate() {
+      let mut x = 0;
+      for StyledGrapheme { symbol, style } in line {
+        let width = symbol.width();
+        if width == 0 {
+          continue;
+        }
+        buf
+          .get_mut(text_area.left() + x, text_area.bottom() - (y as u16) - 1)
+          .set_symbol(if symbol.is_empty() {
+            // If the symbol is empty, the last char which rendered last time will
+            // leave on the line. It's a quick fix.
+            " "
+          } else {
+            symbol
+          })
+          .set_style(*style);
+        x += width as u16;
+      }
+    }
+
+    lines.len() as u16
+  }
+}
diff --git a/src/widgets/mod.rs b/src/widgets/mod.rs
new file mode 100644
index 0000000..85aac48
--- /dev/null
+++ b/src/widgets/mod.rs
@@ -0,0 +1,34 @@
+/*
+ * Copyright (C) 2023  Valentin Lorentz
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License version 3,
+ * as published by the Free Software Foundation.
+ *
+ * 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/>.
+ */
+
+use ratatui::prelude::*;
+use ratatui::widgets::Widget;
+
+mod bottom_aligned_paragraph;
+pub use bottom_aligned_paragraph::BottomAlignedParagraph;
+
+/// A [`Widget`] that returns how many lines it actually drew to.
+pub trait OverlappableWidget {
+  fn render_overlap(self, area: Rect, buf: &mut Buffer) -> u16;
+}
+
+/*
+impl<W: OverlappableWidget> Widget for W {
+  fn render(self, area: Rect, buf: &mut Buffer) {
+    self.render_overlap(area, buf);
+  }
+}
+*/