diff --git a/packages/preview/umbra/0.1.1/.gitignore b/packages/preview/umbra/0.1.1/.gitignore
new file mode 100644
index 0000000000..ad19376013
--- /dev/null
+++ b/packages/preview/umbra/0.1.1/.gitignore
@@ -0,0 +1,4 @@
+*.ttf
+.DS_Store
+OFL.txt
+.vscode
\ No newline at end of file
diff --git a/packages/preview/umbra/0.1.1/LICENSE b/packages/preview/umbra/0.1.1/LICENSE
new file mode 100644
index 0000000000..424170c1de
--- /dev/null
+++ b/packages/preview/umbra/0.1.1/LICENSE
@@ -0,0 +1,7 @@
+Copyright 2024 David Armstrong
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
\ No newline at end of file
diff --git a/packages/preview/umbra/0.1.1/README.md b/packages/preview/umbra/0.1.1/README.md
new file mode 100644
index 0000000000..bc63ef18f3
--- /dev/null
+++ b/packages/preview/umbra/0.1.1/README.md
@@ -0,0 +1,47 @@
+# Umbra
+
+Umbra is a library for drawing basic gradient shadows in [typst](https://typst.app). It currently provides only one function for drawing a shadow along one edge of a path.
+
+## Examples
+
+### Basic Shadow
+
+
+
+
+### Neumorphism
+
+
+
+
+### Torn Paper
+
+
+
+
+*Click on the example image to jump to the code.*
+
+## Usage
+
+The following code creates a very basic square shadow:
+```
+#import "@preview/umbra:0.1.1": shadow-path
+
+#shadow-path((10%, 10%), (10%, 90%), (90%, 90%), (90%, 10%), closed: true)
+```
+
+The function syntax is similar to the normal path syntax. The following arguments were added:
+
+* `shadow-radius` (default `0.5cm`): The shadow size in the direction normal to the edge
+* `shadow-stops` (default `(gray, white)`): The colours to be used in the shadow, passed directly to `gradient`
+* `correction` (default `5deg`): A small correction factor to be added to round shadows at corners. Otherwise, there will be a small gap between the two shadows
+
+### Vertex Order
+
+The order of the vertices defines the direction of the shadow. If the shadow is the wrong way around, just reverse the vertices.
+
+### Transparency
+
+This package is designed in such a way that it should support transparency in the gradients (i.e. corners define shadows using a path which approximates the arc, instead of an entire circle). However, typst doesn't currently support transparency in gradients. ([issue](https://github.com/typst/typst/issues/2546)).
+
+In addition, the aforementioned correction factor would likely cause issues with transparent gradients.
\ No newline at end of file
diff --git a/packages/preview/umbra/0.1.1/gallery/basic.png b/packages/preview/umbra/0.1.1/gallery/basic.png
new file mode 100644
index 0000000000..126e8d3ace
Binary files /dev/null and b/packages/preview/umbra/0.1.1/gallery/basic.png differ
diff --git a/packages/preview/umbra/0.1.1/gallery/basic.typ b/packages/preview/umbra/0.1.1/gallery/basic.typ
new file mode 100644
index 0000000000..09bc60dd8e
--- /dev/null
+++ b/packages/preview/umbra/0.1.1/gallery/basic.typ
@@ -0,0 +1,5 @@
+#import "@preview/umbra:0.1.1": shadow-path
+
+#set page(width: 15cm, height: 15cm, margin: 0.5cm)
+
+#shadow-path((10%, 10%), (10%, 90%), (90%, 90%), (90%, 10%), closed: true)
\ No newline at end of file
diff --git a/packages/preview/umbra/0.1.1/gallery/neumorphism.png b/packages/preview/umbra/0.1.1/gallery/neumorphism.png
new file mode 100644
index 0000000000..a623fd572f
Binary files /dev/null and b/packages/preview/umbra/0.1.1/gallery/neumorphism.png differ
diff --git a/packages/preview/umbra/0.1.1/gallery/neumorphism.typ b/packages/preview/umbra/0.1.1/gallery/neumorphism.typ
new file mode 100644
index 0000000000..7e5cc1ff9d
--- /dev/null
+++ b/packages/preview/umbra/0.1.1/gallery/neumorphism.typ
@@ -0,0 +1,50 @@
+#import "@preview/umbra:0.1.1": shadow-path
+
+#let background-colour = color.rgb("#EFEEEE")
+#let radius = 0.4cm
+
+#set page(width: 15cm, height: 15cm, margin: 0.5cm, fill: background-colour)
+
+#box(
+ {
+ place(shadow-path(
+ shadow-stops: (white.mix((background-colour, 50%)), background-colour,),
+ shadow-radius: radius,
+ (16%, 15%),
+ (15%, 16%),
+ (15%, 79%),
+ (16%, 80%),
+ (79%, 80%),
+ (80%, 79%),
+ (80%, 16%),
+ (79%, 15%),
+ closed: true,
+ ))
+ place(
+ shadow-path(
+ shadow-stops: (color.rgb("#D1CDC7").mix((background-colour, 50%)), background-colour,),
+ shadow-radius: radius,
+ (21%, 20%),
+ (20%, 21%),
+ (20%, 84%),
+ (21%, 85%),
+ (84%, 85%),
+ (85%, 84%),
+ (85%, 20%),
+ (84%, 21%),
+ closed: true,
+ ),
+ )
+ polygon(
+ fill: background-colour,
+ (16%, 15%),
+ (15%, 16%),
+ (15%, 84%),
+ (16%, 85%),
+ (85%, 84%),
+ (84%, 85%),
+ (85%, 16%),
+ (84%, 15%),
+ )
+ },
+)
\ No newline at end of file
diff --git a/packages/preview/umbra/0.1.1/gallery/torn-paper.png b/packages/preview/umbra/0.1.1/gallery/torn-paper.png
new file mode 100644
index 0000000000..e407541333
Binary files /dev/null and b/packages/preview/umbra/0.1.1/gallery/torn-paper.png differ
diff --git a/packages/preview/umbra/0.1.1/gallery/torn-paper.typ b/packages/preview/umbra/0.1.1/gallery/torn-paper.typ
new file mode 100644
index 0000000000..cd44946d51
--- /dev/null
+++ b/packages/preview/umbra/0.1.1/gallery/torn-paper.typ
@@ -0,0 +1,135 @@
+#import "@preview/suiji:0.3.0": *
+#import "@preview/umbra:0.1.1": shadow-path
+
+#set page(width: 13cm, height: 8cm, margin: 0.0cm)
+
+#let torn-paper(
+ tear-positions: (40%, 45%),
+ alignment: left,
+ paper-colour: rgb("ECEBE9"),
+ mult: 0.4%,
+ seed: auto,
+ approx-steps: 100,
+) = {
+ assert(alignment in (left, right, top, bottom))
+ context {
+ let tear-positions = tear-positions
+ if type(tear-positions) != array {
+ tear-positions = (tear-positions, tear-positions)
+ } else if tear-positions.len() == 1 {
+ tear-positions = tear-positions * 2
+ }
+ let seed = seed
+ if seed == auto {
+ seed = counter(page).get().at(0)
+ counter(page).step()
+ }
+ let chunk_size = int(calc.round(approx-steps / (tear-positions.len() - 1)))
+ let steps = chunk_size * (tear-positions.len() - 1)
+ let rng = gen-rng-f(seed)
+ let edge0-vals
+ (rng, edge0-vals) = random-f(rng, size: (steps - 1))
+ edge0-vals = edge0-vals.fold((0%,), (acc, x) => {
+ acc.push(acc.last() + ((x * 2 - 1) * mult))
+ acc
+ })
+ edge0-vals = edge0-vals.chunks(chunk_size).zip(tear-positions, tear-positions.slice(1)).map(
+ ((chunk, prev, next)) => {
+ chunk.enumerate().map(
+ ((i, x)) => {
+ let r = i / (chunk.len() - 1)
+ assert(r <= 1)
+ prev + (next - prev) * r + x - chunk.first() * (1 - r) - chunk.last() * r
+ },
+ )
+ },
+ ).flatten()
+ assert(edge0-vals.len() == steps)
+ assert(edge0-vals.first() == tear-positions.first())
+ assert(edge0-vals.last() == tear-positions.last())
+ edge0-vals = edge0-vals.enumerate().map(((i, x)) => {
+ (x, i * 100% / (steps - 1))
+ })
+
+ let edge1-vals
+ (rng, edge1-vals) = random-f(rng, size: steps)
+ edge1-vals = edge0-vals.rev().zip(edge1-vals).map((((x, y), r)) => (x + r * 0.2% + 2%, y))
+
+ let shadow-vals = edge1-vals
+ shadow-vals.insert(0, shadow-vals.first())
+
+ let edge2-vals = edge1-vals
+ edge2-vals.insert(0, (100%, 100%))
+ edge2-vals.push((100%, 0%))
+
+ edge1-vals = edge1-vals + edge0-vals
+
+ edge0-vals.insert(0, (0%, 0%))
+ edge0-vals.push((0%, 100%))
+
+ if alignment == right {
+ edge0-vals = edge0-vals.map(((x, y)) => (100% - x, y))
+ edge1-vals = edge1-vals.map(((x, y)) => (100% - x, y))
+ edge2-vals = edge2-vals.map(((x, y)) => (100% - x, y))
+ shadow-vals = shadow-vals.map(((x, y)) => (100% - x, y)).rev()
+ } else if alignment == top {
+ edge0-vals = edge0-vals.map(((x, y)) => (y, x))
+ edge1-vals = edge1-vals.map(((x, y)) => (y, x))
+ edge2-vals = edge2-vals.map(((x, y)) => (y, x))
+ shadow-vals = shadow-vals.map(((x, y)) => (y, x))
+ } else if alignment == bottom {
+ edge0-vals = edge0-vals.map(((x, y)) => (y, 100% - x))
+ edge1-vals = edge1-vals.map(((x, y)) => (y, 100% - x))
+ edge2-vals = edge2-vals.map(((x, y)) => (y, 100% - x))
+ shadow-vals = shadow-vals.map(((x, y)) => (y, 100% - x))
+ }
+
+ layout(
+ size => {
+ block(
+ width: 100%,
+ height: 100%,
+ {
+ polygon(fill: paper-colour, stroke: none, ..edge2-vals)
+ polygon(fill: none, stroke: none, ..edge0-vals)
+ place(
+ horizon + left,
+ polygon(fill: white.transparentize(50%), stroke: none, ..edge1-vals),
+ )
+ place(top + left, shadow-path(
+ ..shadow-vals,
+ shadow-stops: (luma(210), paper-colour),
+ shadow-radius: 0.15cm,
+ ))
+ },
+ )
+ },
+ )
+ }
+}
+
+#set text(size: 36pt, font: "Indie Flower")
+
+#show regex("[a-zA-Z]"): (letter) => {
+ context{
+ let seed = counter("letters").get().at(0)
+ counter("letters").step()
+ let rng = gen-rng-f(seed)
+ let x
+ let y
+ (rng, (x, y)) = random-f(rng, size: 2)
+ box(move(dx: x * 0.1cm, dy: y * 0.2cm - 0.1cm, letter))
+ }
+}
+
+#place(horizon + left, [#h(0.8cm) at a quarter to twelve
+
+ #h(3.3cm) learn what
+
+ #h(7.7cm) maybe])
+
+#place(torn-paper(
+ tear-positions: (100%, 95%, 86%, 78%, 70%, 50%, 30%, 0%),
+ alignment: right,
+ approx-steps: 250,
+))
diff --git a/packages/preview/umbra/0.1.1/src/lib.typ b/packages/preview/umbra/0.1.1/src/lib.typ
new file mode 100644
index 0000000000..859d9a42ef
--- /dev/null
+++ b/packages/preview/umbra/0.1.1/src/lib.typ
@@ -0,0 +1,3 @@
+#let version = version((0, 1, 0))
+
+#import "shadow-path.typ": shadow-path
diff --git a/packages/preview/umbra/0.1.1/src/shadow-path.typ b/packages/preview/umbra/0.1.1/src/shadow-path.typ
new file mode 100644
index 0000000000..2edf47a459
--- /dev/null
+++ b/packages/preview/umbra/0.1.1/src/shadow-path.typ
@@ -0,0 +1,225 @@
+// Gets the length of an arbitrary vector
+#let _norm(p) = calc.sqrt(p.map(x => calc.pow(x.pt(), 2)).sum()) * 1pt
+// Adds any number of arbitrary vectors
+#let _add(..ps) = ps.pos().fold(
+ none,
+ (acc, x) => if acc == none { x } else { acc.zip(x).map(((y, z)) => y + z) },
+)
+// Takes the first vector and subtracts all subsequent vectors from it
+#let _sub(..ps) = ps.pos().fold(
+ none,
+ (acc, x) => if acc == none { x } else { acc.zip(x).map(((y, z)) => y - z) },
+)
+// Rotates a 2D vector by the given angle
+#let _rot(p, angle) = (
+ p.first() * calc.cos(angle) - p.last() * calc.sin(angle),
+ p.first() * calc.sin(angle) + p.last() * calc.cos(angle),
+)
+// Multiply (scale) a vector by some number
+#let _mult(p, x) = p.map(y => x * y)
+// Roll a vector by count positions, moving the overflow at the end back to the start
+#let _roll(arr, count) = (arr.slice(count) + arr).slice(0, arr.len())
+
+#let shadow-path(
+ fill: none,
+ stroke: none,
+ closed: false,
+ shadow-radius: 0.5cm,
+ shadow-stops: (gray, white),
+ // A small correction is required otherwise there is a white line between shadow sections
+ correction: 5deg,
+ ..vertices,
+) = {
+ let vertices = vertices.pos()
+ assert(
+ vertices.all(x => x.len() == 2 and x.all(y => type(y) != array)),
+ message: "paths with Bezier control points not supported",
+ )
+ layout(
+ size => {
+ let vertices = vertices.map(((x, y)) => (
+ if type(x) == ratio { x * size.width } else { x },
+ if type(y) == ratio { y * size.height } else { y },
+ ))
+
+ let groups = vertices.zip(_roll(vertices, 1), _roll(vertices, 2), _roll(vertices, 3))
+ if not closed {
+ groups = _roll(groups, -1).slice(0, -1)
+ }
+
+ // Setup edge shadows
+ for (p0, p1, p2, p3) in groups {
+ let angle0 = calc.atan2(.._sub(p1, p0).map(x => x.pt()))
+ angle0 += if angle0 > 0deg { 0deg } else { 360deg }
+ let angle1 = calc.atan2(.._sub(p2, p1).map(x => x.pt()))
+ angle1 += if angle1 > 0deg { 0deg } else { 360deg }
+ let angle2 = calc.atan2(.._sub(p3, p2).map(x => x.pt()))
+ angle2 += if angle2 > 0deg { 0deg } else { 360deg }
+
+ let width = shadow-radius
+ let height = _norm(_sub(p1, p2))
+ let d0 = 0pt
+ let d1 = 0pt
+
+ let da0 = angle1 - angle0
+ let da1 = angle2 - angle1
+ if da0 < 0deg or da0 > 180deg {
+ da0 = 0
+ }
+ if da1 < 0deg or da1 > 180deg {
+ da1 = 0
+ }
+ place(top + left, dx: p2.first(), dy: p2.last(), rotate(
+ calc.atan2(.._sub(p1, p2).map(x => x.pt())) + 90deg + 180deg,
+ origin: top + left,
+ polygon(
+ fill: gradient.linear(..shadow-stops),
+ (0pt, 0pt),
+ _rot((width, 0pt), da1 / 2),
+ _add((0pt, height), _rot((width, 0pt), -da0 / 2)),
+ (0pt, height),
+ ),
+ ))
+ }
+
+ // Setup corner shadows
+ if not closed {
+ groups = groups.slice(1)
+ }
+
+ for (p0, p1, p2, p3) in groups {
+ let angle0 = calc.atan2(.._sub(p1, p0).map(x => x.pt()))
+ angle0 += if angle0 > 0deg { 0deg } else { 360deg }
+ let angle1 = calc.atan2(.._sub(p2, p1).map(x => x.pt()))
+ angle1 += if angle1 > 0deg { 0deg } else { 360deg }
+
+ let da = angle1 - angle0
+ if da < 0deg or da > 180deg {
+ da = calc.abs(da)
+ let d0 = _rot((shadow-radius, 0pt), angle0 + 90deg + correction)
+ let d1 = _rot((shadow-radius, 0pt), angle1 + 90deg - correction)
+ // Must be placed in the correct location, otherwise the gradient is based on the size of the whole box
+ place(
+ top + left,
+ dx: p1.first() - shadow-radius,
+ dy: p1.last() - shadow-radius,
+ box(
+ // A fixed size box is required to make radial gradient work. For PDF, the gradient doesn't actually have to be contained by the box, but this breaks with PNG, hence the extra complexity
+ width: 2 * shadow-radius,
+ height: 2 * shadow-radius,
+ place(
+ top + left,
+ dx: 50%,
+ dy: 50%,
+ curve(
+ fill: gradient.radial(..shadow-stops, center: (50%, 50%), radius: 50%, relative: "parent"),
+ curve.move((0pt, 0pt)),
+ curve.line(d1),
+ curve.cubic(
+ _add(d1, _mult(_rot(d1, 90deg), calc.sin(da / 2))),
+ _add(d0, _mult(_rot(d0, -90deg), calc.sin(da / 2))),
+ d0,
+ ),
+ curve.close(mode: "straight"),
+ ),
+ ),
+ ),
+ )
+ }
+ }
+
+ if fill != none or stroke != none {
+ curve(fill: fill, stroke: stroke,
+ curve.move(vertices.first()),
+ ..vertices.slice(1).map(curve.line),
+ ..if closed {
+ (curve.close(),)
+ },
+ )
+ }
+ },
+ )
+}
+
+#let shadow-circle(
+ radius: 0pt,
+ width: auto,
+ height: auto,
+ fill: none,
+ stroke: none,
+ inset: 5pt,
+ outset: (:),
+ shadow-radius: 0.5cm,
+ shadow-stops: (gray, white),
+ // A small correction factor to avoid a line between the shadow and the fill
+ correction: 0.00001%,
+ ..body,
+) = {
+ assert(
+ (radius, width, height).filter(it => it == 0pt or it == auto or it == none).len() >= 2,
+ message: "radius, width and height are mutually exclusive",
+ )
+ layout(
+ size => {
+ // Replicate the built in optional positional body argument
+ assert(
+ body.named().len() == 0 and body.pos().len() <= 1,
+ message: "unexpected argument",
+ )
+ let body = if body.pos().len() == 1 { body.pos().first() } else { none }
+
+ // Width and height seem to only be to allow for sizing relative to parent
+ let radius = radius
+ if not (width == 0pt or width == auto or width == none) {
+ if type(width) == ratio {
+ radius = width * size.width
+ } else {
+ radius = width
+ }
+ } else if not (height == 0pt or height == auto or height == none) {
+ if type(height) == ratio {
+ radius = height * size.height
+ } else {
+ radius = height
+ }
+ }
+
+ // Avoid an unnecessary place
+ let inner = circle(
+ radius: radius + shadow-radius,
+ fill: gradient.radial(
+ // Making it transparent doesn't actually do anything yet since gradients
+ // can't handle transparency
+ (shadow-stops.last().transparentize(100%), 0%),
+ (
+ shadow-stops.last().transparentize(100%),
+ radius / (radius + shadow-radius) * 100% - correction,
+ ),
+ ..shadow-stops.enumerate().map(
+ ((i, stop)) => (
+ stop,
+ (radius + shadow-radius * i / (shadow-stops.len() - 1)) / (radius + shadow-radius) * 100%,
+ ),
+ ),
+ ),
+ stroke: none,
+ inset: inset,
+ outset: outset,
+ )
+
+ if fill != none or stroke != none or body != none {
+ place(inner)
+ place(dx: shadow-radius, dy: shadow-radius, circle(
+ radius: radius,
+ fill: fill,
+ stroke: stroke,
+ inset: inset,
+ outset: outset,
+ body,
+ ))
+ } else {
+ inner
+ }
+ },
+ )
+}
diff --git a/packages/preview/umbra/0.1.1/typst.toml b/packages/preview/umbra/0.1.1/typst.toml
new file mode 100644
index 0000000000..85872d195b
--- /dev/null
+++ b/packages/preview/umbra/0.1.1/typst.toml
@@ -0,0 +1,14 @@
+[package]
+name = "umbra"
+version = "0.1.1"
+compiler = "0.13.0"
+repository = "https://github.com/davystrong/umbra"
+entrypoint = "src/lib.typ"
+authors = [
+ "David Armstrong "
+]
+license = "MIT"
+description = "Basic shadows for Typst"
+categories = [ "visualization" ]
+keywords = [ "shadow", "path", "border", "gradient" ]
+exclude = [ "/gallery/*"]