diff --git a/src-svelte/src/routes/BackgroundUI.svelte b/src-svelte/src/routes/BackgroundUI.svelte
index 50e305b3..c23709f8 100644
--- a/src-svelte/src/routes/BackgroundUI.svelte
+++ b/src-svelte/src/routes/BackgroundUI.svelte
@@ -1,16 +1,11 @@
-<script lang="ts">
-  import { onMount } from "svelte";
-  import { standardDuration } from "$lib/preferences";
-  import prand from "pure-rand";
-
-  const rng = prand.xoroshiro128plus(8650539321744612);
+<script lang="ts" context="module">
   const CHAR_EM = 26;
   const CHAR_GAP = 2;
   const TEXT_FONT = CHAR_EM + "px 'Zhi Mang Xing', sans-serif";
   const BLOCK_SIZE = CHAR_EM + CHAR_GAP;
   const ANIMATES_PER_CHAR = 2;
   const STATIC_INITIAL_DRAWS = 100;
-  const DDJ = [
+  export const DDJ = [
     "道可道非常道",
     "名可名非常名",
     "无名天地之始",
@@ -22,6 +17,18 @@
     "玄之又玄",
     "众妙之门",
   ];
+
+  export function getDdjLineNumber(column: number, numFullColumns: number) {
+    return (numFullColumns - 1 - column + DDJ.length) % DDJ.length;
+  }
+</script>
+
+<script lang="ts">
+  import { onMount } from "svelte";
+  import { standardDuration } from "$lib/preferences";
+  import prand from "pure-rand";
+
+  const rng = prand.xoroshiro128plus(8650539321744612);
   export let animated = false;
   $: animateIntervalMs = $standardDuration / 2;
   let background: HTMLDivElement | null = null;
@@ -31,6 +38,7 @@
   let dropsPosition: number[] = [];
   let dropsAnimateCounter: number[] = [];
   let numColumns = 0;
+  let numFullColumns = 0;
   let numRows = 0;
 
   function stopAnimating() {
@@ -66,7 +74,10 @@
 
     canvas.width = background.clientWidth;
     canvas.height = background.clientHeight;
+    // note that BLOCK_SIZE already contains CHAR_GAP
+    // this is just adding CHAR_GAP-sized left padding to the animation
     numColumns = Math.ceil((canvas.width - CHAR_GAP) / BLOCK_SIZE);
+    numFullColumns = Math.round((canvas.width - CHAR_GAP) / BLOCK_SIZE - 0.1);
     numRows = Math.ceil(canvas.height / BLOCK_SIZE);
 
     ctx = canvas.getContext("2d");
@@ -100,7 +111,7 @@
     ctx.font = TEXT_FONT;
 
     for (var column = 0; column < dropsPosition.length; column++) {
-      const textLine = DDJ[column % DDJ.length];
+      const textLine = DDJ[getDdjLineNumber(column, numFullColumns)];
       const textCharacter = textLine[dropsPosition[column] % textLine.length];
       ctx.fillText(
         textCharacter,
diff --git a/src-svelte/src/routes/BackgroundUI.test.ts b/src-svelte/src/routes/BackgroundUI.test.ts
new file mode 100644
index 00000000..af2be2f8
--- /dev/null
+++ b/src-svelte/src/routes/BackgroundUI.test.ts
@@ -0,0 +1,32 @@
+import { getDdjLineNumber, DDJ } from "./BackgroundUI.svelte";
+
+describe("Dao De Jing positioning", () => {
+  it("should put DDJ's first line on the right if there's one column", () => {
+    expect(getDdjLineNumber(0, 1)).toEqual(0);
+  });
+
+  it("should put DDJ's first line on the right if there's two columns", () => {
+    expect(getDdjLineNumber(0, 2)).toEqual(1);
+    expect(getDdjLineNumber(1, 2)).toEqual(0);
+  });
+
+  it("should loop around if there's enough columns", () => {
+    const numColumns = 25; // DDJ has 10 lines, so this will loop around twice
+    // we start from the right of the screen and read line by line to the left
+    expect(getDdjLineNumber(24, numColumns)).toEqual(0);
+    expect(getDdjLineNumber(23, numColumns)).toEqual(1);
+    // we check that it wraps around twice, each time after it finishes all 10 lines
+    expect(getDdjLineNumber(15, numColumns)).toEqual(9);
+    expect(getDdjLineNumber(14, numColumns)).toEqual(0);
+    expect(getDdjLineNumber(5, numColumns)).toEqual(9);
+    expect(getDdjLineNumber(4, numColumns)).toEqual(0);
+    // the left-most columns end wherever it is in order
+    expect(getDdjLineNumber(1, numColumns)).toEqual(3);
+    expect(getDdjLineNumber(0, numColumns)).toEqual(4);
+  });
+
+  it("should always return last line for partial columns", () => {
+    expect(getDdjLineNumber(1, 1)).toEqual(DDJ.length - 1);
+    expect(getDdjLineNumber(25, 25)).toEqual(DDJ.length - 1);
+  });
+});