diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c3d75a3..4a212ec 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -12,38 +12,25 @@ on:
   workflow_dispatch:
 
 jobs:
-  fmt:
-    runs-on: ubuntu-20.04
+  go:
+    runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v2
-
-      - name: make fmt
-        uses: ./ci/image
+      - uses: actions/checkout@v3
+      - name: Cache npm
+        uses: actions/cache@v3
         with:
-          args: make fmt
-
-  lint:
-    runs-on: ubuntu-20.04
-    steps:
-      - uses: actions/checkout@v2
-
-      - name: make lint
-        uses: ./ci/image
+          path: ~/.npm
+          key: "npm-cache"
+      - uses: actions/setup-go@v4
         with:
-          args: make lint
-
-  test:
-    runs-on: ubuntu-20.04
-    steps:
-      - uses: actions/checkout@v2
-
-      - name: make test
-        uses: ./ci/image
-        with:
-          args: make test
+          go-version: "1.20"
+          cache-dependency-path: go.sum
+      - name: "make"
+        run: |
+          git config --global --add safe.directory /github/workspace
+          make -O -j fmt lint test
         env:
           COVERALLS_TOKEN: ${{ secrets.github_token }}
-
       - name: Upload coverage.html
         uses: actions/upload-artifact@v2
         with:
diff --git a/ci/fmt.mk b/ci/fmt.mk
index 026cc36..7f74874 100644
--- a/ci/fmt.mk
+++ b/ci/fmt.mk
@@ -1,4 +1,4 @@
-fmt: modtidy gofmt goimports prettier
+fmt: modtidy gofmt prettier
 ifdef CI
 	if [[ $$(git ls-files --other --modified --exclude-standard) != "" ]]; then
 	  echo "Files need generation or are formatted incorrectly:"
@@ -13,13 +13,10 @@ modtidy: gen
 	go mod tidy
 
 gofmt: gen
-	gofmt -w -s .
-
-goimports: gen
-	goimports -w "-local=$$(go list -m)" .
+	go run mvdan.cc/gofumpt@latest -w .
 
 prettier:
-	prettier --write --print-width=120 --no-semi --trailing-comma=all --loglevel=warn $$(git ls-files "*.yml")
+	npx prettier --write --print-width=120 --no-semi --trailing-comma=all --loglevel=warn $$(git ls-files "*.yml")
 
 gen:
 	go generate ./...
diff --git a/ci/image/Dockerfile b/ci/image/Dockerfile
deleted file mode 100644
index 2c6988e..0000000
--- a/ci/image/Dockerfile
+++ /dev/null
@@ -1,14 +0,0 @@
-FROM golang:1
-
-RUN apt-get update && \
-    apt-get install -y npm
-
-ENV GOFLAGS="-mod=readonly"
-ENV PAGER=cat
-ENV CI=true
-ENV MAKEFLAGS="--jobs=8 --output-sync=target"
-
-RUN npm install -g prettier
-RUN go install golang.org/x/tools/cmd/goimports@latest
-RUN go install golang.org/x/lint/golint@latest
-RUN go install github.com/mattn/goveralls@latest
diff --git a/ci/lint.mk b/ci/lint.mk
index fbf42d2..36da85b 100644
--- a/ci/lint.mk
+++ b/ci/lint.mk
@@ -4,4 +4,4 @@ govet:
 	go vet ./...
 
 golint:
-	golint -set_exit_status ./...
+	go run github.com/golangci/golangci-lint/cmd/golangci-lint@latest run .
diff --git a/ci/test.mk b/ci/test.mk
index 2615c51..35bfd05 100644
--- a/ci/test.mk
+++ b/ci/test.mk
@@ -15,7 +15,7 @@ coveralls: gotest
 	  export CI_PULL_REQUEST="$$(jq .number "$$GITHUB_EVENT_PATH")"
 	  BUILD_NUMBER="$$BUILD_NUMBER-PR-$$CI_PULL_REQUEST"
 	fi
-	goveralls -coverprofile=ci/out/coverage.prof -service=github
+	go run github.com/mattn/goveralls@latest -coverprofile=ci/out/coverage.prof -service=github
 
 gotest:
 	go test -covermode=count -coverprofile=ci/out/coverage.prof -coverpkg=./... $${GOTESTFLAGS-} ./...
diff --git a/example_test.go b/example_test.go
index 2853ff7..95131ee 100644
--- a/example_test.go
+++ b/example_test.go
@@ -8,7 +8,6 @@ import (
 	"testing"
 	"time"
 
-	"go.opencensus.io/trace"
 	"golang.org/x/xerrors"
 
 	"cdr.dev/slog"
@@ -72,23 +71,13 @@ func Example_testing() {
 		slog.F("field_name", "something or the other"),
 	)
 
-	// t.go:55: 2019-12-05 21:20:31.218 [INFO]	<examples_test.go:42>	my message here	{"field_name": "something or the other"}
-}
-
-func Example_tracing() {
-	log := slog.Make(sloghuman.Sink(os.Stdout))
-
-	ctx, _ := trace.StartSpan(context.Background(), "spanName")
-
-	log.Info(ctx, "my msg", slog.F("hello", "hi"))
-
-	// 2019-12-09 21:59:48.110 [INFO]	<example_test.go:62>	my msg	{"trace": "f143d018d00de835688453d8dc55c9fd", "span": "f214167bf550afc3", "hello": "hi"}
+	// t.go:55: 2019-12-05 21:20:31.218 [INFO]	my message here	field_name="something or the other"
 }
 
 func Example_multiple() {
 	l := slog.Make(sloghuman.Sink(os.Stdout))
 
-	f, err := os.OpenFile("stackdriver", os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0644)
+	f, err := os.OpenFile("stackdriver", os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o644)
 	if err != nil {
 		l.Fatal(context.Background(), "failed to open stackdriver log file", slog.Error(err))
 	}
@@ -97,7 +86,7 @@ func Example_multiple() {
 
 	l.Info(context.Background(), "log to stdout and stackdriver")
 
-	// 2019-12-07 20:59:55.790 [INFO]	<example_test.go:46>	log to stdout and stackdriver
+	// 2019-12-07 20:59:55.790 [INFO]	log to stdout and stackdriver
 }
 
 func ExampleWith() {
@@ -106,7 +95,7 @@ func ExampleWith() {
 	l := slog.Make(sloghuman.Sink(os.Stdout))
 	l.Info(ctx, "msg")
 
-	// 2019-12-07 20:54:23.986 [INFO]	<example_test.go:20>	msg	{"field": 1}
+	// 2019-12-07 20:54:23.986 [INFO]	msg	field=1}
 }
 
 func ExampleStdlib() {
@@ -115,7 +104,7 @@ func ExampleStdlib() {
 
 	l.Print("msg")
 
-	// 2019-12-07 20:54:23.986 [INFO]	(stdlib)	<example_test.go:29>	msg	{"field": 1}
+	// 2019-12-07 20:54:23.986 [INFO]	(stdlib)	msg	field=1
 }
 
 func ExampleLogger_Named() {
@@ -125,7 +114,7 @@ func ExampleLogger_Named() {
 	l = l.Named("http")
 	l.Info(ctx, "received request", slog.F("remote address", net.IPv4(127, 0, 0, 1)))
 
-	// 2019-12-07 21:20:56.974 [INFO]	(http)	<example_test.go:85>	received request	{"remote address": "127.0.0.1"}
+	// 2019-12-07 21:20:56.974 [INFO]	(http)	received request	remote_address=127.0.0.1}
 }
 
 func ExampleLogger_Leveled() {
@@ -139,6 +128,6 @@ func ExampleLogger_Leveled() {
 
 	l.Debug(ctx, "testing2")
 
-	// 2019-12-07 21:26:20.945 [INFO]	<example_test.go:95>	received request
-	// 2019-12-07 21:26:20.945 [DEBUG]	<example_test.go:99>	testing2
+	// 2019-12-07 21:26:20.945 [INFO]	received request
+	// 2019-12-07 21:26:20.945 [DEBU]	testing2
 }
diff --git a/go.mod b/go.mod
index ac067d3..c1fd46d 100644
--- a/go.mod
+++ b/go.mod
@@ -1,15 +1,31 @@
 module cdr.dev/slog
 
-go 1.13
+go 1.20
 
 require (
 	cloud.google.com/go v0.26.0
-	github.com/alecthomas/chroma v0.10.0
-	github.com/fatih/color v1.13.0
+	github.com/charmbracelet/lipgloss v0.7.1
 	github.com/google/go-cmp v0.5.3
+	github.com/muesli/termenv v0.15.1
 	go.opencensus.io v0.24.0
-	go.uber.org/goleak v1.2.1 // indirect
+	go.uber.org/goleak v1.2.1
 	golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
 	golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2
 	google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013
 )
+
+require (
+	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
+	github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e // indirect
+	github.com/golang/protobuf v1.4.3 // indirect
+	github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
+	github.com/mattn/go-isatty v0.0.17 // indirect
+	github.com/mattn/go-runewidth v0.0.14 // indirect
+	github.com/muesli/reflow v0.3.0 // indirect
+	github.com/rivo/uniseg v0.2.0 // indirect
+	golang.org/x/net v0.0.0-20201110031124-69a78807bb2b // indirect
+	golang.org/x/sys v0.6.0 // indirect
+	golang.org/x/text v0.3.3 // indirect
+	google.golang.org/grpc v1.33.2 // indirect
+	google.golang.org/protobuf v1.25.0 // indirect
+)
diff --git a/go.sum b/go.sum
index 7de8934..4b64d35 100644
--- a/go.sum
+++ b/go.sum
@@ -1,22 +1,20 @@
 cloud.google.com/go v0.26.0 h1:e0WKqKTd5BnrG8aKH3J3h+QvEIQtSUcf2n5UZ5ZgLtQ=
 cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
-github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek=
-github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
 github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
+github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZpc5Kc1E=
+github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c=
 github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
 github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E=
-github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
 github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
 github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
 github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
 github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
-github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
-github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
 github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
 github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e h1:1r7pUrabqp18hOBcwBwiTsbnFeTZHV9eER/QT5JVZxY=
 github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@@ -39,21 +37,26 @@ github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
 github.com/google/go-cmp v0.5.3 h1:x95R7cp+rSeeqAMI2knLtQ0DKlaBhv2NrtrOvafPHRo=
 github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
-github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
-github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
-github.com/mattn/go-colorable v0.1.9 h1:sqDoxXbdeALODt0DAeJCVp38ps9ZogZEAXjus69YV3U=
-github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
-github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
-github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
-github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
+github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
+github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
+github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
+github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
+github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
+github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
+github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
+github.com/muesli/termenv v0.15.1 h1:UzuTb/+hhlBugQz28rpzey4ZuKcZ03MeKsoG7IJZIxs=
+github.com/muesli/termenv v0.15.1/go.mod h1:HeAQPTzpfs016yGtA4g00CsdYnVLJvxsS4ANqrZs2sQ=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
+github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
-github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
 github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
@@ -83,11 +86,10 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ
 golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c h1:F1jZWGFhYfh0Ci55sIpILtKKK8p3i2/krTr0H1rg74I=
-golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -121,9 +123,7 @@ google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2
 google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
 google.golang.org/protobuf v1.25.0 h1:Ejskq+SyPohKW+1uil0JJMtmHCgJPJ/qWTxr8qp+R4c=
 google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
-gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/entryhuman/entry.go b/internal/entryhuman/entry.go
index 4e741c9..6c8406d 100644
--- a/internal/entryhuman/entry.go
+++ b/internal/entryhuman/entry.go
@@ -8,13 +8,14 @@ import (
 	"fmt"
 	"io"
 	"os"
-	"path/filepath"
-	"runtime/debug"
+	"reflect"
 	"strconv"
 	"strings"
 	"time"
+	"unicode"
 
-	"github.com/fatih/color"
+	"github.com/charmbracelet/lipgloss"
+	"github.com/muesli/termenv"
 	"go.opencensus.io/trace"
 	"golang.org/x/crypto/ssh/terminal"
 	"golang.org/x/xerrors"
@@ -34,15 +35,49 @@ func StripTimestamp(ent string) (time.Time, string, error) {
 // TimeFormat is a simplified RFC3339 format.
 const TimeFormat = "2006-01-02 15:04:05.000"
 
-func c(w io.Writer, attrs ...color.Attribute) *color.Color {
-	c := color.New(attrs...)
-	c.DisableColor()
+var (
+	renderer = lipgloss.NewRenderer(os.Stdout, termenv.WithUnsafe())
+
+	loggerNameStyle = renderer.NewStyle().Foreground(lipgloss.Color("#A47DFF"))
+	timeStyle       = renderer.NewStyle().Foreground(lipgloss.Color("#606366"))
+)
+
+func render(w io.Writer, st lipgloss.Style, s string) string {
 	if shouldColor(w) {
-		c.EnableColor()
+		ss := st.Render(s)
+		return ss
+	}
+	return s
+}
+
+func reset(w io.Writer, termW io.Writer) {
+	if shouldColor(termW) {
+		fmt.Fprintf(w, termenv.CSI+termenv.ResetSeq+"m")
+	}
+}
+
+func formatValue(v interface{}) string {
+	typ := reflect.TypeOf(v)
+	switch typ.Kind() {
+	case reflect.Struct, reflect.Map:
+		byt, err := json.Marshal(v)
+		if err != nil {
+			panic(err)
+		}
+		return string(byt)
+	case reflect.Slice:
+		// Byte slices are optimistically readable.
+		if typ.Elem().Kind() == reflect.Uint8 {
+			return fmt.Sprintf("%q", v)
+		}
+		fallthrough
+	default:
+		return quote(fmt.Sprintf("%+v", v))
 	}
-	return c
 }
 
+const tab = "  "
+
 // Fmt returns a human readable format for ent.
 //
 // We never return with a trailing newline because Go's testing framework adds one
@@ -50,26 +85,31 @@ func c(w io.Writer, attrs ...color.Attribute) *color.Color {
 // We also do not indent the fields as go's test does that automatically
 // for extra lines in a log so if we did it here, the fields would be indented
 // twice in test logs. So the Stderr logger indents all the fields itself.
-func Fmt(w io.Writer, ent slog.SinkEntry) string {
-	ents := c(w, color.Reset).Sprint("")
+func Fmt(
+	buf interface {
+		io.StringWriter
+		io.Writer
+	}, termW io.Writer, ent slog.SinkEntry,
+) {
+	reset(buf, termW)
 	ts := ent.Time.Format(TimeFormat)
-	ents += ts + " "
+	buf.WriteString(render(termW, timeStyle, ts+" "))
 
-	level := "[" + ent.Level.String() + "]"
-	level = c(w, levelColor(ent.Level)).Sprint(level)
-	ents += fmt.Sprintf("%v\t", level)
+	level := ent.Level.String()
+	level = strings.ToLower(level)
+	if len(level) > 4 {
+		level = level[:4]
+	}
+	level = "[" + level + "]"
+	buf.WriteString(render(termW, levelStyle(ent.Level), level))
+	buf.WriteString("  ")
 
 	if len(ent.LoggerNames) > 0 {
 		loggerName := "(" + quoteKey(strings.Join(ent.LoggerNames, ".")) + ")"
-		loggerName = c(w, color.FgMagenta).Sprint(loggerName)
-		ents += fmt.Sprintf("%v\t", loggerName)
+		buf.WriteString(render(termW, loggerNameStyle, loggerName))
+		buf.WriteString(tab)
 	}
 
-	hpath, hfn := humanPathAndFunc(ent.File, ent.Func)
-	loc := fmt.Sprintf("<%v:%v>\t%v", hpath, ent.Line, hfn)
-	loc = c(w, color.FgCyan).Sprint(loc)
-	ents += fmt.Sprintf("%v\t", loc)
-
 	var multilineKey string
 	var multilineVal string
 	msg := strings.TrimSpace(ent.Message)
@@ -77,9 +117,9 @@ func Fmt(w io.Writer, ent slog.SinkEntry) string {
 		multilineKey = "msg"
 		multilineVal = msg
 		msg = "..."
+		msg = quote(msg)
 	}
-	msg = quote(msg)
-	ents += msg
+	buf.WriteString(msg)
 
 	if ent.SpanContext != (trace.SpanContext{}) {
 		ent.Fields = append(slog.M(
@@ -111,48 +151,61 @@ func Fmt(w io.Writer, ent slog.SinkEntry) string {
 		multilineVal = s
 	}
 
-	if len(ent.Fields) > 0 {
-		// No error is guaranteed due to slog.Map handling errors itself.
-		fields, _ := json.MarshalIndent(ent.Fields, "", "")
-		fields = bytes.ReplaceAll(fields, []byte(",\n"), []byte(", "))
-		fields = bytes.ReplaceAll(fields, []byte("\n"), []byte(""))
-		fields = formatJSON(w, fields)
-		ents += "\t" + string(fields)
+	keyStyle := timeStyle
+	// Help users distinguish logs by keeping some color in the equal signs.
+	equalsStyle := timeStyle
+
+	for i, f := range ent.Fields {
+		if i < len(ent.Fields) {
+			buf.WriteString(tab)
+		}
+		buf.WriteString(render(termW, keyStyle, quoteKey(f.Name)))
+		buf.WriteString(render(termW, equalsStyle, "="))
+		valueStr := formatValue(f.Value)
+		buf.WriteString(valueStr)
 	}
 
 	if multilineVal != "" {
 		if msg != "..." {
-			ents += " ..."
+			buf.WriteString(" ...")
 		}
 
 		// Proper indentation.
 		lines := strings.Split(multilineVal, "\n")
 		for i, line := range lines[1:] {
 			if line != "" {
-				lines[i+1] = c(w, color.Reset).Sprint("") + strings.Repeat(" ", len(multilineKey)+4) + line
+				lines[i+1] = strings.Repeat(" ", len(multilineKey)+2) + line
 			}
 		}
 		multilineVal = strings.Join(lines, "\n")
 
-		multilineKey = c(w, color.FgBlue).Sprintf(`"%v"`, multilineKey)
-		ents += fmt.Sprintf("\n%v: %v", multilineKey, multilineVal)
+		multilineKey = render(termW, keyStyle, multilineKey)
+		buf.WriteString("\n")
+		buf.WriteString(multilineKey)
+		buf.WriteString("= ")
+		buf.WriteString(multilineVal)
 	}
-
-	return ents
 }
 
-func levelColor(level slog.Level) color.Attribute {
+var (
+	levelDebugStyle = timeStyle.Copy()
+	levelInfoStyle  = renderer.NewStyle().Foreground(lipgloss.Color("#0091FF"))
+	levelWarnStyle  = renderer.NewStyle().Foreground(lipgloss.Color("#FFCF0D"))
+	levelErrorStyle = renderer.NewStyle().Foreground(lipgloss.Color("#FF5A0D"))
+)
+
+func levelStyle(level slog.Level) lipgloss.Style {
 	switch level {
 	case slog.LevelDebug:
-		return color.Reset
+		return levelDebugStyle
 	case slog.LevelInfo:
-		return color.FgBlue
+		return levelInfoStyle
 	case slog.LevelWarn:
-		return color.FgYellow
-	case slog.LevelError:
-		return color.FgRed
+		return levelWarnStyle
+	case slog.LevelError, slog.LevelFatal, slog.LevelCritical:
+		return levelErrorStyle
 	default:
-		return color.FgHiRed
+		panic("unknown level")
 	}
 }
 
@@ -182,11 +235,18 @@ func quote(key string) string {
 		return `""`
 	}
 
+	var hasSpace bool
+	for _, r := range key {
+		if unicode.IsSpace(r) {
+			hasSpace = true
+			break
+		}
+	}
 	quoted := strconv.Quote(key)
 	// If the key doesn't need to be quoted, don't quote it.
 	// We do not use strconv.CanBackquote because it doesn't
 	// account tabs.
-	if quoted[1:len(quoted)-1] == key {
+	if !hasSpace && quoted[1:len(quoted)-1] == key {
 		return key
 	}
 	return quoted
@@ -194,66 +254,5 @@ func quote(key string) string {
 
 func quoteKey(key string) string {
 	// Replace spaces in the map keys with underscores.
-	return strings.ReplaceAll(key, " ", "_")
-}
-
-var mainPackagePath string
-var mainModulePath string
-
-func init() {
-	// Unfortunately does not work for tests yet :(
-	// See https://github.com/golang/go/issues/33976
-	bi, ok := debug.ReadBuildInfo()
-	if !ok {
-		return
-	}
-	mainPackagePath = bi.Path
-	mainModulePath = bi.Main.Path
-}
-
-// humanPathAndFunc takes the absolute path to a file and an absolute module path to a
-// function in that file and returns the module path to the file. It also returns
-// the path to the function stripped of its module prefix.
-//
-// If the file is in the main Go module then its path is returned
-// relative to the main Go module's root.
-//
-// fn is from https://pkg.go.dev/runtime#Func.Name
-func humanPathAndFunc(filename, fn string) (hpath, hfn string) {
-	// pkgDir is the dir of the pkg.
-	//   e.g. cdr.dev/slog/internal
-	// base is the package name and the function name separated by a period.
-	//   e.g. entryhuman.humanPathAndFunc
-	// There can be multiple periods when methods of types are involved.
-	pkgDir, base := filepath.Split(fn)
-	s := strings.Split(base, ".")
-	pkg := s[0]
-	hfn = strings.Join(s[1:], ".")
-
-	if pkg == "main" {
-		// This happens with go build main.go
-		if mainPackagePath == "command-line-arguments" {
-			// Without a real mainPath, we can't find the path to the file
-			// relative to the module. So we just return the base.
-			return filepath.Base(filename), hfn
-		}
-		// Go doesn't return the full main path in runtime.Func.Name()
-		// It just returns the path "main"
-		// Only runtime.ReadBuildInfo returns it so we have to check and replace.
-		pkgDir = mainPackagePath
-		// pkg main isn't reflected on the disk so we should not add it
-		// into the pkgpath.
-		pkg = ""
-	}
-
-	hpath = filepath.Join(pkgDir, pkg, filepath.Base(filename))
-
-	if mainModulePath != "" {
-		relhpath, err := filepath.Rel(mainModulePath, hpath)
-		if err == nil {
-			hpath = "./" + relhpath
-		}
-	}
-
-	return hpath, hfn
+	return quote(strings.ReplaceAll(key, " ", "_"))
 }
diff --git a/internal/entryhuman/entry_test.go b/internal/entryhuman/entry_test.go
index f7fc596..6c62827 100644
--- a/internal/entryhuman/entry_test.go
+++ b/internal/entryhuman/entry_test.go
@@ -1,12 +1,14 @@
 package entryhuman_test
 
 import (
-	"io/ioutil"
+	"bytes"
+	"flag"
+	"fmt"
+	"io"
+	"os"
 	"testing"
 	"time"
 
-	"go.opencensus.io/trace"
-
 	"cdr.dev/slog"
 	"cdr.dev/slog/internal/assert"
 	"cdr.dev/slog/internal/entryhuman"
@@ -14,81 +16,177 @@ import (
 
 var kt = time.Date(2000, time.February, 5, 4, 4, 4, 4, time.UTC)
 
+var updateGoldenFiles = flag.Bool("update-golden-files", false, "update golden files in testdata")
+
+type testObj struct {
+	foo int
+	bar int
+	dra []byte
+}
+
 func TestEntry(t *testing.T) {
 	t.Parallel()
 
-	test := func(t *testing.T, in slog.SinkEntry, exp string) {
-		act := entryhuman.Fmt(ioutil.Discard, in)
-		assert.Equal(t, "entry", exp, act)
+	type tcase struct {
+		name string
+		ent  slog.SinkEntry
 	}
 
-	t.Run("basic", func(t *testing.T) {
-		t.Parallel()
-
-		test(t, slog.SinkEntry{
-			Message: "wowowow\tizi",
-			Time:    kt,
-			Level:   slog.LevelDebug,
-
-			File: "myfile",
-			Line: 100,
-			Func: "mypkg.ignored",
-		}, `2000-02-05 04:04:04.000 [DEBUG]	<mypkg/myfile:100>	ignored	"wowowow\tizi"`)
-	})
-
-	t.Run("multilineMessage", func(t *testing.T) {
-		t.Parallel()
-
-		test(t, slog.SinkEntry{
-			Message: "line1\nline2",
-			Level:   slog.LevelInfo,
-		}, `0001-01-01 00:00:00.000 [INFO]	<.:0>		...
-"msg": line1
-       line2`)
-	})
-
-	t.Run("multilineField", func(t *testing.T) {
-		t.Parallel()
-
-		test(t, slog.SinkEntry{
-			Message: "msg",
-			Level:   slog.LevelInfo,
-			Fields:  slog.M(slog.F("field", "line1\nline2")),
-		}, `0001-01-01 00:00:00.000 [INFO]	<.:0>		msg ...
-"field": line1
-         line2`)
-	})
-
-	t.Run("named", func(t *testing.T) {
-		t.Parallel()
-
-		test(t, slog.SinkEntry{
-			Level:       slog.LevelWarn,
-			LoggerNames: []string{"named", "meow"},
-		}, `0001-01-01 00:00:00.000 [WARN]	(named.meow)	<.:0>		""`)
-	})
-
-	t.Run("trace", func(t *testing.T) {
-		t.Parallel()
-
-		test(t, slog.SinkEntry{
-			Level: slog.LevelError,
-			SpanContext: trace.SpanContext{
-				SpanID:  trace.SpanID{0, 1, 2, 3, 4, 5, 6, 7},
-				TraceID: trace.TraceID{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15},
+	ents := []tcase{
+		{
+			"simpleNoFields",
+			slog.SinkEntry{
+				Message: "wowowow\tizi",
+				Time:    kt,
+				Level:   slog.LevelDebug,
+
+				File: "myfile",
+				Line: 100,
+				Func: "mypkg.ignored",
 			},
-		}, `0001-01-01 00:00:00.000 [ERROR]	<.:0>		""	{"trace": "000102030405060708090a0b0c0d0e0f", "span": "0001020304050607"}`)
-	})
-
-	t.Run("color", func(t *testing.T) {
-		t.Parallel()
+		},
+		{
+			"multilineMessage",
+			slog.SinkEntry{
+				Message: "line1\nline2",
+				Level:   slog.LevelInfo,
+			},
+		},
+		{
+			"multilineField",
+			slog.SinkEntry{
+				Message: "msg",
+				Level:   slog.LevelInfo,
+				Fields:  slog.M(slog.F("field", "line1\nline2")),
+			},
+		},
+		{
+			"named",
+			slog.SinkEntry{
+				Level:       slog.LevelWarn,
+				LoggerNames: []string{"named", "meow"},
+			},
+		},
+		{
+			"funky",
+			slog.SinkEntry{
+				Level: slog.LevelWarn,
+				Fields: slog.M(
+					slog.F("funky^%&^&^key", "value"),
+					slog.F("funky^%&^&^key2", "@#\t \t \n"),
+				),
+			},
+		},
+		{
+			"spacey",
+			slog.SinkEntry{
+				Level: slog.LevelWarn,
+				Fields: slog.M(
+					slog.F("space in my key", "value in my value"),
+				),
+			},
+		},
+		{
+			"bytes",
+			slog.SinkEntry{
+				Level: slog.LevelWarn,
+				Fields: slog.M(
+					slog.F("somefile", []byte("blah bla\x01h blah")),
+				),
+			},
+		},
+		{
+			"object",
+			slog.SinkEntry{
+				Level: slog.LevelWarn,
+				Fields: slog.M(
+					slog.F("obj", slog.M(
+						slog.F("obj1", testObj{
+							foo: 1,
+							bar: 2,
+							dra: []byte("blah"),
+						}),
+						slog.F("obj2", testObj{
+							foo: 3,
+							bar: 4,
+							dra: []byte("blah"),
+						}),
+					)),
+					slog.F("map", map[string]string{
+						"key1": "value1",
+					}),
+				),
+			},
+		},
+	}
+	if *updateGoldenFiles {
+		ents, err := os.ReadDir("testdata")
+		if err != nil {
+			t.Fatal(err)
+		}
+		for _, ent := range ents {
+			os.Remove("testdata/" + ent.Name())
+		}
+	}
 
-		act := entryhuman.Fmt(entryhuman.ForceColorWriter, slog.SinkEntry{
-			Level: slog.LevelCritical,
-			Fields: slog.M(
-				slog.F("hey", "hi"),
-			),
+	for _, tc := range ents {
+		tc := tc
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+			goldenPath := fmt.Sprintf("testdata/%s.golden", tc.name)
+
+			var gotBuf bytes.Buffer
+			entryhuman.Fmt(&gotBuf, io.Discard, tc.ent)
+
+			if *updateGoldenFiles {
+				err := os.WriteFile(goldenPath, gotBuf.Bytes(), 0o644)
+				if err != nil {
+					t.Fatal(err)
+				}
+				return
+			}
+
+			wantByt, err := os.ReadFile(goldenPath)
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			assert.Equal(t, "entry matches", string(wantByt), gotBuf.String())
 		})
-		assert.Equal(t, "entry", "\x1b[0m\x1b[0m0001-01-01 00:00:00.000 \x1b[91m[CRITICAL]\x1b[0m\t\x1b[36m<.:0>	\x1b[0m\t\"\"\t{\x1b[34m\"hey\"\x1b[0m: \x1b[32m\"hi\"\x1b[0m}", act)
-	})
+	}
+}
+
+func BenchmarkFmt(b *testing.B) {
+	bench := func(b *testing.B, color bool) {
+		nfs := []int{1, 4, 16}
+		for _, nf := range nfs {
+			name := fmt.Sprintf("nf=%v", nf)
+			if color {
+				name = "Colored-" + name
+			}
+			b.Run(name, func(b *testing.B) {
+				fs := make([]slog.Field, nf)
+				for i := 0; i < nf; i++ {
+					fs[i] = slog.F("key", "value")
+				}
+				se := slog.SinkEntry{
+					Level: slog.LevelCritical,
+					Fields: slog.M(
+						fs...,
+					),
+				}
+				w := io.Discard
+				if color {
+					w = entryhuman.ForceColorWriter
+				}
+				b.ResetTimer()
+				b.ReportAllocs()
+				for i := 0; i < b.N; i++ {
+					entryhuman.Fmt(bytes.NewBuffer(nil), w, se)
+				}
+			})
+		}
+	}
+	bench(b, true)
+	bench(b, false)
 }
diff --git a/internal/entryhuman/json.go b/internal/entryhuman/json.go
deleted file mode 100644
index 25f65f6..0000000
--- a/internal/entryhuman/json.go
+++ /dev/null
@@ -1,42 +0,0 @@
-package entryhuman
-
-import (
-	"bytes"
-	"io"
-
-	"github.com/alecthomas/chroma"
-	"github.com/alecthomas/chroma/formatters"
-	jlexers "github.com/alecthomas/chroma/lexers/j"
-)
-
-// Adapted from https://github.com/alecthomas/chroma/blob/2f5349aa18927368dbec6f8c11608bf61c38b2dd/styles/bw.go#L7
-// https://github.com/alecthomas/chroma/blob/2f5349aa18927368dbec6f8c11608bf61c38b2dd/formatters/tty_indexed.go
-// https://github.com/alecthomas/chroma/blob/2f5349aa18927368dbec6f8c11608bf61c38b2dd/lexers/j/json.go
-var style = chroma.MustNewStyle("slog", chroma.StyleEntries{
-	// Magenta.
-	chroma.Keyword: "#7f007f",
-	// Magenta.
-	chroma.Number: "#7f007f",
-	// Magenta.
-	chroma.Name: "#00007f",
-	// Green.
-	chroma.String: "#007f00",
-})
-
-var jsonLexer = chroma.Coalesce(jlexers.JSON)
-
-func formatJSON(w io.Writer, buf []byte) []byte {
-	if !shouldColor(w) {
-		return buf
-	}
-
-	highlighted, _ := colorizeJSON(buf)
-	return highlighted
-}
-
-func colorizeJSON(buf []byte) ([]byte, error) {
-	it, _ := jsonLexer.Tokenise(nil, string(buf))
-	b := &bytes.Buffer{}
-	formatters.TTY8.Format(b, style, it)
-	return b.Bytes(), nil
-}
diff --git a/internal/entryhuman/logfmt/README.md b/internal/entryhuman/logfmt/README.md
new file mode 100644
index 0000000..8d2cd17
--- /dev/null
+++ b/internal/entryhuman/logfmt/README.md
@@ -0,0 +1,25 @@
+# logfmt
+
+logfmt provides an implementation that supports nested objects and arrays. It
+is meant to be a drop-in replacement for JSON, which other logfmt implements
+do not support.
+
+This package makes the trade-off of being more difficult for computers to parse
+in favor of the human.
+
+See these examples:
+
+JSON:
+```json
+{ "user": { "id": 123, "name": "foo", "age": 20, "hobbies": ["basketball", "football"] } }
+```
+
+flat logfmt:
+```
+user.id=123 user.name=foo user.age=20 user.hobbies="basketball,football"
+```
+
+nested logfmt:
+```
+user={ id=123 name=foo age=20 hobbies=[basketball football] }
+```
diff --git a/internal/entryhuman/logfmt/encoder.go b/internal/entryhuman/logfmt/encoder.go
new file mode 100644
index 0000000..98d9d18
--- /dev/null
+++ b/internal/entryhuman/logfmt/encoder.go
@@ -0,0 +1,118 @@
+package logfmt
+
+import (
+	"fmt"
+	"io"
+	"reflect"
+	"strconv"
+	"unicode"
+)
+
+type Encoder struct {
+	w           io.Writer
+	FormatKey   func(key string) string
+	// FormatPrimitiveValue is used to format primitive values (strings, ints,
+	// floats, etc). It is not used for arrays or objects.
+	FormatPrimitiveValue func(value interface{}) string
+}
+
+func NewEncoder(w io.Writer) *Encoder {
+	return &Encoder{
+		FormatKey: func(key string) string { return key },
+		FormatPrimitiveValue: func(value interface{}) string { return fmt.Sprintf("%+v", value) },
+		w: w,
+	}
+}
+
+func isPrimitive(typ reflect.Type) bool {
+	switch typ.Kind() {
+	case reflect.Bool:
+		return true
+	case reflect.String:
+		return true
+	case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
+		return true
+	case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
+		return true
+	case reflect.Float32, reflect.Float64:
+		return true
+	case reflect.Complex64, reflect.Complex128:
+		return true
+	default:
+		return false
+	}
+}
+
+// Encode encodes the given message to the writer. For flat objects, the
+// output resembles key=value pairs. For nested objects, a surrounding { } is
+// used. For arrays, a surrounding [ ] is used.
+func (e *Encoder) Encode(m interface{}) error {
+	typ := reflect.TypeOf(m)
+	if typ.Kind() == reflect.Ptr {
+		typ = typ.Elem()
+	}
+
+	if isPrimitive(typ) {
+		e.w.Write([]byte(e.FormatPrimitiveValue(m)))
+		return nil
+	}
+
+	switch typ.Kind() {
+	case reflect.Struct:
+		v := reflect.ValueOf(m)
+		for i := 0; i < typ.NumField(); i++ {
+			field := typ.Field(i)
+			value := v.Field(i)
+			if !value.CanInterface() {
+				continue
+			}
+			if value.IsZero() {
+				continue
+			}
+			if field.Anonymous {
+				if err := e.Encode(value.Interface()); err != nil {
+					return err
+				}
+				continue
+			}
+			if e.FormatKey != nil {
+				e.w.Write([]byte(e.FormatKey(field.Name)))
+			} else {
+				e.w.Write([]byte(field.Name))
+			}
+			e.w.Write([]byte("="))
+			if e.FormatPrimitiveValue != nil {
+				e.w.Write([]byte(e.FormatPrimitiveValue(value.Interface())))
+			} else {
+				e.w.Write([]byte(formatValue(value.Interface())))
+			}
+	default:
+		return fmt.Errorf("unsupported type %T", m)
+	}
+}
+
+// quotes quotes a string so that it is suitable
+// as a key for a map or in general some output that
+// cannot span multiple lines or have weird characters.
+func quote(key string) string {
+	// strconv.Quote does not quote an empty string so we need this.
+	if key == "" {
+		return `""`
+	}
+
+	var hasSpace bool
+	for _, r := range key {
+		if unicode.IsSpace(r) {
+			hasSpace = true
+			break
+		}
+	}
+	quoted := strconv.Quote(key)
+	// If the key doesn't need to be quoted, don't quote it.
+	// We do not use strconv.CanBackquote because it doesn't
+	// account tabs.
+	if !hasSpace && quoted[1:len(quoted)-1] == key {
+		return key
+	}
+	return quoted
+}
diff --git a/internal/entryhuman/testdata/bytes.golden b/internal/entryhuman/testdata/bytes.golden
new file mode 100644
index 0000000..e4c5490
--- /dev/null
+++ b/internal/entryhuman/testdata/bytes.golden
@@ -0,0 +1 @@
+0001-01-01 00:00:00.000 [warn]    somefile="blah bla\x01h blah"
\ No newline at end of file
diff --git a/internal/entryhuman/testdata/funky.golden b/internal/entryhuman/testdata/funky.golden
new file mode 100644
index 0000000..fc6a460
--- /dev/null
+++ b/internal/entryhuman/testdata/funky.golden
@@ -0,0 +1 @@
+0001-01-01 00:00:00.000 [warn]    funky^%&^&^key=value  funky^%&^&^key2="@#\t \t \n"
\ No newline at end of file
diff --git a/internal/entryhuman/testdata/multilineField.golden b/internal/entryhuman/testdata/multilineField.golden
new file mode 100644
index 0000000..a2777d8
--- /dev/null
+++ b/internal/entryhuman/testdata/multilineField.golden
@@ -0,0 +1,3 @@
+0001-01-01 00:00:00.000 [info]  msg ...
+field= line1
+       line2
\ No newline at end of file
diff --git a/internal/entryhuman/testdata/multilineMessage.golden b/internal/entryhuman/testdata/multilineMessage.golden
new file mode 100644
index 0000000..233fda6
--- /dev/null
+++ b/internal/entryhuman/testdata/multilineMessage.golden
@@ -0,0 +1,3 @@
+0001-01-01 00:00:00.000 [info]  ...
+msg= line1
+     line2
\ No newline at end of file
diff --git a/internal/entryhuman/testdata/named.golden b/internal/entryhuman/testdata/named.golden
new file mode 100644
index 0000000..09efb6e
--- /dev/null
+++ b/internal/entryhuman/testdata/named.golden
@@ -0,0 +1 @@
+0001-01-01 00:00:00.000 [warn]  (named.meow)  
\ No newline at end of file
diff --git a/internal/entryhuman/testdata/object.golden b/internal/entryhuman/testdata/object.golden
new file mode 100644
index 0000000..855cb06
--- /dev/null
+++ b/internal/entryhuman/testdata/object.golden
@@ -0,0 +1 @@
+0001-01-01 00:00:00.000 [warn]    obj="[{Name:obj1 Value:{foo:1 bar:2 dra:[98 108 97 104]}} {Name:obj2 Value:{foo:3 bar:4 dra:[98 108 97 104]}}]"  map={"key1":"value1"}
\ No newline at end of file
diff --git a/internal/entryhuman/testdata/simpleNoFields.golden b/internal/entryhuman/testdata/simpleNoFields.golden
new file mode 100644
index 0000000..db46f6a
--- /dev/null
+++ b/internal/entryhuman/testdata/simpleNoFields.golden
@@ -0,0 +1 @@
+2000-02-05 04:04:04.000 [debu]  wowowow	izi
\ No newline at end of file
diff --git a/internal/entryhuman/testdata/spacey.golden b/internal/entryhuman/testdata/spacey.golden
new file mode 100644
index 0000000..7135d8c
--- /dev/null
+++ b/internal/entryhuman/testdata/spacey.golden
@@ -0,0 +1 @@
+0001-01-01 00:00:00.000 [warn]    space_in_my_key="value in my value"
\ No newline at end of file
diff --git a/map_test.go b/map_test.go
index e15a6ee..fce13b5 100644
--- a/map_test.go
+++ b/map_test.go
@@ -187,7 +187,7 @@ func TestMap(t *testing.T) {
 		t.Parallel()
 
 		test(t, slog.M(
-			slog.F("val", time.Date(2000, 02, 05, 4, 4, 4, 0, time.UTC)),
+			slog.F("val", time.Date(2000, 0o2, 0o5, 4, 4, 4, 0, time.UTC)),
 		), `{
 			"val": "2000-02-05T04:04:04Z"
 		}`)
@@ -222,10 +222,6 @@ func TestMap(t *testing.T) {
 	})
 }
 
-type meow struct {
-	a int
-}
-
 func indentJSON(t *testing.T, j string) string {
 	b := &bytes.Buffer{}
 	err := json.Indent(b, []byte(j), "", strings.Repeat(" ", 4))
diff --git a/s_test.go b/s_test.go
index 4e3b9e1..19578c9 100644
--- a/s_test.go
+++ b/s_test.go
@@ -23,5 +23,5 @@ func TestStdlib(t *testing.T) {
 	et, rest, err := entryhuman.StripTimestamp(b.String())
 	assert.Success(t, "strip timestamp", err)
 	assert.False(t, "timestamp", et.IsZero())
-	assert.Equal(t, "entry", " [INFO]\t(stdlib)\t<cdr.dev/slog_test/s_test.go:21>\tTestStdlib\tstdlib\t{\"hi\": \"we\"}\n", rest)
+	assert.Equal(t, "entry", " [info]  (stdlib)  stdlib  hi=we\n", rest)
 }
diff --git a/sloggers/sloghuman/sloghuman.go b/sloggers/sloghuman/sloghuman.go
index b872c74..5247d17 100644
--- a/sloggers/sloghuman/sloghuman.go
+++ b/sloggers/sloghuman/sloghuman.go
@@ -3,9 +3,11 @@
 package sloghuman // import "cdr.dev/slog/sloggers/sloghuman"
 
 import (
+	"bufio"
+	"bytes"
 	"context"
 	"io"
-	"strings"
+	"sync"
 
 	"cdr.dev/slog"
 	"cdr.dev/slog/internal/entryhuman"
@@ -29,24 +31,40 @@ type humanSink struct {
 	w2 io.Writer
 }
 
+var bufPool = sync.Pool{
+	New: func() interface{} {
+		return bytes.NewBuffer(make([]byte, 0, 256))
+	},
+}
+
 func (s humanSink) LogEntry(ctx context.Context, ent slog.SinkEntry) {
-	str := entryhuman.Fmt(s.w2, ent)
-	lines := strings.Split(str, "\n")
+	buf1 := bufPool.Get().(*bytes.Buffer)
+	buf1.Reset()
+	defer bufPool.Put(buf1)
+
+	buf2 := bufPool.Get().(*bytes.Buffer)
+	buf2.Reset()
+	defer bufPool.Put(buf2)
+
+	entryhuman.Fmt(buf1, s.w2, ent)
+
+	var (
+		i  int
+		sc = bufio.NewScanner(buf1)
+	)
 
 	// We need to add 4 spaces before every field line for readability.
 	// humanfmt doesn't do it for us because the testSink doesn't want
 	// it as *testing.T automatically does it.
-	fieldsLines := lines[1:]
-	for i, line := range fieldsLines {
-		if line == "" {
-			continue
+	for ; sc.Scan(); i++ {
+		if i > 0 && len(sc.Bytes()) > 0 {
+			buf2.Write([]byte("    "))
 		}
-		fieldsLines[i] = strings.Repeat(" ", 2) + line
+		buf2.Write(sc.Bytes())
+		buf2.WriteByte('\n')
 	}
 
-	str = strings.Join(lines, "\n")
-
-	s.w.Write("sloghuman", []byte(str+"\n"))
+	s.w.Write("sloghuman", buf2.Bytes())
 }
 
 func (s humanSink) Sync() {
diff --git a/sloggers/sloghuman/sloghuman_test.go b/sloggers/sloghuman/sloghuman_test.go
index 1c26631..9047161 100644
--- a/sloggers/sloghuman/sloghuman_test.go
+++ b/sloggers/sloghuman/sloghuman_test.go
@@ -3,6 +3,8 @@ package sloghuman_test
 import (
 	"bytes"
 	"context"
+	"fmt"
+	"os"
 	"testing"
 
 	"cdr.dev/slog"
@@ -24,5 +26,20 @@ func TestMake(t *testing.T) {
 	et, rest, err := entryhuman.StripTimestamp(b.String())
 	assert.Success(t, "strip timestamp", err)
 	assert.False(t, "timestamp", et.IsZero())
-	assert.Equal(t, "entry", " [INFO]\t<cdr.dev/slog/sloggers/sloghuman_test/sloghuman_test.go:21>\tTestMake\t...\t{\"wowow\": \"me\\nyou\"}\n  \"msg\": line1\n\n         line2\n", rest)
+	assert.Equal(t, "entry", " [info]  ...  wowow=\"me\\nyou\"\n    msg= line1\n\n         line2\n", rest)
+}
+
+func TestVisual(t *testing.T) {
+	t.Setenv("FORCE_COLOR", "true")
+	if os.Getenv("TEST_VISUAL") == "" {
+		t.Skip("TEST_VISUAL not set")
+	}
+
+	l := slog.Make(sloghuman.Sink(os.Stdout)).Leveled(slog.LevelDebug)
+	l.Debug(bg, "small potatos", slog.F("aaa", "mmm"), slog.F("bbb", "nnn"), slog.F("age", 24))
+	l.Info(bg, "line1\n\nline2", slog.F("wowow", "me\nyou"))
+	l.Warn(bg, "oops", slog.F("aaa", "mmm"))
+	l = l.Named("sublogger")
+	l.Error(bg, "big oops", slog.F("aaa", "mmm"), slog.Error(fmt.Errorf("this happened\nand this")))
+	l.Sync()
 }
diff --git a/sloggers/slogtest/assert/assert.go b/sloggers/slogtest/assert/assert.go
index e11476f..1e3c456 100644
--- a/sloggers/slogtest/assert/assert.go
+++ b/sloggers/slogtest/assert/assert.go
@@ -89,5 +89,4 @@ func stringContainsFold(errs, sub string) bool {
 	sub = strings.ToLower(sub)
 
 	return strings.Contains(errs, sub)
-
 }
diff --git a/sloggers/slogtest/assert/assert_test.go b/sloggers/slogtest/assert/assert_test.go
index 49f6c02..b483cf3 100644
--- a/sloggers/slogtest/assert/assert_test.go
+++ b/sloggers/slogtest/assert/assert_test.go
@@ -44,11 +44,10 @@ func TestErrorContains(t *testing.T) {
 	defer func() {
 		recover()
 		simpleassert.Equal(t, "fatals", 1, tb.fatals)
-
 	}()
 	assert.ErrorContains(tb, "meow", io.ErrClosedPipe, "eof")
-
 }
+
 func TestSuccess(t *testing.T) {
 	t.Parallel()
 
diff --git a/sloggers/slogtest/t.go b/sloggers/slogtest/t.go
index 646a03d..ace01c8 100644
--- a/sloggers/slogtest/t.go
+++ b/sloggers/slogtest/t.go
@@ -9,6 +9,7 @@ import (
 	"context"
 	"log"
 	"os"
+	"strings"
 	"sync"
 	"testing"
 
@@ -73,8 +74,9 @@ func (ts *testSink) LogEntry(ctx context.Context, ent slog.SinkEntry) {
 		return
 	}
 
+	var s strings.Builder
 	// The testing package logs to stdout and not stderr.
-	s := entryhuman.Fmt(os.Stdout, ent)
+	entryhuman.Fmt(&s, os.Stdout, ent)
 
 	switch ent.Level {
 	case slog.LevelDebug, slog.LevelInfo, slog.LevelWarn: