diff --git a/ChangeLog.md b/ChangeLog.md
new file mode 100644
--- /dev/null
+++ b/ChangeLog.md
@@ -0,0 +1,15 @@
+# ChangeLog for sockaddr
+
+## 0.1.0
+
+The output format of IPv6 addresses is changed.
+
+- IPv6 addresses are compressed according to RFC 5952.
+- Non-zero scope IDs of IPv6 addresses are appended, e.g. `fe80::1%4`.
+- Using `Builder` instead of `printf`.
+- Dropping the dependency on `byteorder`.
+- Adding a test suite.
+
+## 0.0.1
+
+- Initial release.
diff --git a/Network/SockAddr.hs b/Network/SockAddr.hs
--- a/Network/SockAddr.hs
+++ b/Network/SockAddr.hs
@@ -1,76 +1,94 @@
--- | Converting an address in 'SockAddr'.
+-- | Converting the address part of 'SockAddr' (without port).
 
 module Network.SockAddr (
     showSockAddr
   , showSockAddrBS
   ) where
 
-import Data.Bits (shift, (.&.))
+import Data.Bits (shiftR)
 import Data.ByteString (ByteString)
-import qualified Data.ByteString.Char8 as BS (pack)
-import Network.Socket (SockAddr(..), HostAddress, HostAddress6)
-import System.ByteOrder
-import Text.Printf
-
-isReversed :: Bool
-isReversed = byteOrder == LittleEndian
+import qualified Data.ByteString.Builder as B
+import qualified Data.ByteString.Char8 as C8
+import qualified Data.ByteString.Lazy as BL
+import Data.List (intersperse)
+import Data.Word (Word16, Word8)
+import Network.Socket (
+    HostAddress6,
+    SockAddr (..),
+    hostAddress6ToTuple,
+    hostAddressToTuple,
+ )
 
 ----------------------------------------------------------------
 
--- | Convert 'SockAddr' to 'String'. If the address is
---   an IPv4-embedded IPv6 address, the IPv4 is extracted.
+-- | Convert the address part of 'SockAddr' to 'String'.
+--   The port number is not included. If the address is
+--   an IPv4-mapped IPv6 address (@::ffff:0:0/96@),
+--   it is shown as IPv4. A non-zero scope ID of IPv6 is appended
+--   as a number, e.g. @fe80::1%4@.
+--   For 'SockAddrUnix', @"unknownSocket"@ is returned
+--   so that its path is not exposed.
 --
 -- >>> import Network.Socket
--- >>> as <- getAddrInfo (Just defaultHints) (Just "example.org") (Just "http")
--- >>> map (showSockAddr.addrAddress) as
--- ["93.184.216.119","93.184.216.119","2606:2800:220:6d:26bf:1447:1097:aa7","2606:2800:220:6d:26bf:1447:1097:aa7"]
+-- >>> showSockAddr $ SockAddrInet 80 (tupleToHostAddress (192,0,2,1))
+-- "192.0.2.1"
+-- >>> showSockAddr $ SockAddrInet6 80 0 (tupleToHostAddress6 (0x2001,0xdb8,1,2,3,4,5,6)) 0
+-- "2001:db8:1:2:3:4:5:6"
+-- >>> showSockAddr $ SockAddrInet6 80 0 (tupleToHostAddress6 (0x2001,0xdb8,0,0,1,0,0,1)) 0
+-- "2001:db8::1:0:0:1"
 showSockAddr :: SockAddr -> String
-showSockAddr (SockAddrInet _ addr4)                       = showIPv4 addr4 isReversed
-showSockAddr (SockAddrInet6 _ _ (0,0,0x0000ffff,addr4) _) = showIPv4 addr4 False
-showSockAddr (SockAddrInet6 _ _ (0,0,0,1) _)              = "::1"
-showSockAddr (SockAddrInet6 _ _ addr6 _)                  = showIPv6 addr6
-showSockAddr _                                            = "unknownSocket"
+showSockAddr = C8.unpack . showSockAddrBS
 
-----------------------------------------------------------------
+-- | Convert the address part of 'SockAddr' to 'ByteString'.
+--   The port number is not included. If the address is
+--   an IPv4-mapped IPv6 address (@::ffff:0:0/96@),
+--   it is shown as IPv4. A non-zero scope ID of IPv6 is appended
+--   as a number, e.g. @fe80::1%4@.
+--   For 'SockAddrUnix', @"unknownSocket"@ is returned
+--   so that its path is not exposed.
+--
+-- >>> import Network.Socket
+-- >>> showSockAddrBS $ SockAddrInet6 80 0 (tupleToHostAddress6 (0,0,0,0,0,0,0,1)) 0
+-- "::1"
+showSockAddrBS :: SockAddr -> ByteString
+showSockAddrBS = BL.toStrict . B.toLazyByteString . sockAddrBuilder
 
--- HostAddress is network byte order.
-showIPv4 :: HostAddress -> Bool-> String
-showIPv4 w32 reversed
-  | reversed  = show b1 ++ "." ++ show b2 ++ "." ++ show b3 ++ "." ++ show b4
-  | otherwise = show b4 ++ "." ++ show b3 ++ "." ++ show b2 ++ "." ++ show b1
-  where
-    t1 = w32
-    t2 = shift t1 (-8)
-    t3 = shift t2 (-8)
-    t4 = shift t3 (-8)
-    b1 = t1 .&. 0x000000ff
-    b2 = t2 .&. 0x000000ff
-    b3 = t3 .&. 0x000000ff
-    b4 = t4 .&. 0x000000ff
+----------------------------------------------------------------
 
--- HostAddress6 is host byte order.
-showIPv6 :: HostAddress6 -> String
-showIPv6 (w1,w2,w3,w4) =
-    printf "%x:%x:%x:%x:%x:%x:%x:%x" s1 s2 s3 s4 s5 s6 s7 s8
+sockAddrBuilder :: SockAddr -> B.Builder
+sockAddrBuilder (SockAddrInet _ addr4) = buildIPv4 $ hostAddressToTuple addr4
+sockAddrBuilder (SockAddrInet6 _ _ addr6 scope) = case hostAddress6ToTuple addr6 of
+    (0, 0, 0, 0, 0, 0xffff, h, l) -> buildIPv4 (hi h, lo h, hi l, lo l)
+    _ -> buildIPv6 addr6 <> zone
   where
-    (s1,s2) = split16 w1
-    (s3,s4) = split16 w2
-    (s5,s6) = split16 w3
-    (s7,s8) = split16 w4
-    split16 w = (h1,h2)
-      where
-        h1 = shift w (-16) .&. 0x0000ffff
-        h2 = w .&. 0x0000ffff
+    zone
+        | scope == 0 = mempty
+        | otherwise = B.char7 '%' <> B.word32Dec scope
+    hi w = fromIntegral (w `shiftR` 8)
+    lo w = fromIntegral w
+sockAddrBuilder (SockAddrUnix _) = B.string7 "unknownSocket"
 
-----------------------------------------------------------------
+buildIPv4 :: (Word8, Word8, Word8, Word8) -> B.Builder
+buildIPv4 (a, b, c, d) = mconcat $ intersperse (B.char7 '.') $ map B.word8Dec [a, b, c, d]
 
--- | Convert 'SockAddr' to 'ByteString'. If the address is
---   an IPv4-embedded IPv6 address, the IPv4 is extracted.
---
--- >>> import Network.Socket
--- >>> as <- getAddrInfo (Just defaultHints) (Just "localhost") (Just "http")
--- >>> map (showSockAddrBS.addrAddress) as
--- ["127.0.0.1","::1","fe80:0:0:0:0:0:0:1"]
+-- RFC 5952: the longest (first if tied) run of two or more
+-- zero fields is compressed to "::".
+buildIPv6 :: HostAddress6 -> B.Builder
+buildIPv6 addr6 = case longestZeros ws of
+    Just (i, n) -> hexes (take i ws) <> B.string7 "::" <> hexes (drop (i + n) ws)
+    Nothing -> hexes ws
+  where
+    (a, b, c, d, e, f, g, h) = hostAddress6ToTuple addr6
+    ws = [a, b, c, d, e, f, g, h]
+    hexes = mconcat . intersperse (B.char7 ':') . map B.word16Hex
 
-showSockAddrBS :: SockAddr -> ByteString
-showSockAddrBS = BS.pack . showSockAddr
+-- Returns (start index, length) of the zero run to be compressed.
+longestZeros :: [Word16] -> Maybe (Int, Int)
+longestZeros ws = case runs of
+    [] -> Nothing
+    _ -> Just $ foldr1 (\x y -> if snd y > snd x then y else x) runs
+  where
+    runs = [(i, n) | (i, n) <- go 0 ws, n >= 2]
+    go _ [] = []
+    go i xs@(0 : _) = let n = length (takeWhile (== 0) xs) in (i, n) : go (i + n) (drop n xs)
+    go i (_ : xs) = go (i + 1) xs
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,10 @@
+# sockaddr
+
+Converting the address part of `SockAddr` to `String` or `ByteString`.
+
+## Testing
+
+```
+cabal test
+cabal repl --with-compiler=doctest
+```
diff --git a/sockaddr.cabal b/sockaddr.cabal
--- a/sockaddr.cabal
+++ b/sockaddr.cabal
@@ -1,24 +1,44 @@
-Name:                   sockaddr
-Version:                0.0.1
-Author:                 Kazu Yamamoto <kazu@iij.ad.jp>
-Maintainer:             Kazu Yamamoto <kazu@iij.ad.jp>
-License:                BSD3
-License-File:           LICENSE
-Synopsis:               Printing SockAddr
-Description:            Printing SockAddr
-Category:               Network
-Cabal-Version:          >= 1.10
-Build-Type:             Simple
+cabal-version:      2.4
+name:               sockaddr
+version:            0.1.0
+license:            BSD-3-Clause
+license-file:       LICENSE
+author:             Kazu Yamamoto <kazu@iij.ad.jp>
+maintainer:         Kazu Yamamoto <kazu@iij.ad.jp>
+synopsis:           Printing SockAddr
+description:
+    Converting the address part of 'SockAddr' to 'String' or 'ByteString'.
 
-Library
-  Default-Language:     Haskell2010
-  GHC-Options:          -Wall
-  Exposed-Modules:      Network.SockAddr
-  Build-Depends:        base >= 4 && < 5
-                      , byteorder
-                      , bytestring
-                      , network
+category:           Network
+build-type:         Simple
+tested-with:
+    GHC == 9.2 || == 9.4 || == 9.6 || == 9.8 || == 9.10 || == 9.12
+extra-doc-files:
+    ChangeLog.md
+    README.md
 
-Source-Repository head
-  Type:                 git
-  Location:             https://github.com/kazu-yamamoto/sockaddr
+source-repository head
+    type:     git
+    location: https://github.com/kazu-yamamoto/sockaddr
+
+library
+    exposed-modules:  Network.SockAddr
+    default-language: Haskell2010
+    ghc-options:      -Wall
+    build-depends:
+        base >=4 && <5,
+        bytestring >=0.10 && <0.13,
+        network >=3.0 && <3.3
+
+test-suite spec
+    type:               exitcode-stdio-1.0
+    main-is:            Spec.hs
+    hs-source-dirs:     test
+    default-language:   Haskell2010
+    default-extensions: OverloadedStrings
+    ghc-options:        -Wall
+    build-depends:
+        base,
+        hspec,
+        network,
+        sockaddr
diff --git a/test/Spec.hs b/test/Spec.hs
new file mode 100644
--- /dev/null
+++ b/test/Spec.hs
@@ -0,0 +1,45 @@
+module Main (main) where
+
+import Data.Word (Word16, Word8)
+import Network.SockAddr
+import Network.Socket
+import Test.Hspec
+
+v4 :: (Word8, Word8, Word8, Word8) -> SockAddr
+v4 t = SockAddrInet 80 (tupleToHostAddress t)
+
+v6 :: (Word16, Word16, Word16, Word16, Word16, Word16, Word16, Word16) -> SockAddr
+v6 t = SockAddrInet6 80 0 (tupleToHostAddress6 t) 0
+
+main :: IO ()
+main = hspec $ do
+    describe "showSockAddr" $ do
+        it "shows IPv4" $ do
+            showSockAddr (v4 (127, 0, 0, 1)) `shouldBe` "127.0.0.1"
+            showSockAddr (v4 (192, 0, 2, 255)) `shouldBe` "192.0.2.255"
+        it "shows IPv4-mapped IPv6 as IPv4" $
+            showSockAddr (v6 (0, 0, 0, 0, 0, 0xffff, 0xc000, 0x0201))
+                `shouldBe` "192.0.2.1"
+        it "shows IPv6 loopback" $
+            showSockAddr (v6 (0, 0, 0, 0, 0, 0, 0, 1)) `shouldBe` "::1"
+        it "shows IPv6" $
+            showSockAddr (v6 (0x2001, 0xdb8, 1, 2, 3, 4, 5, 6))
+                `shouldBe` "2001:db8:1:2:3:4:5:6"
+        it "compresses zeros (RFC 5952)" $ do
+            showSockAddr (v6 (0, 0, 0, 0, 0, 0, 0, 0)) `shouldBe` "::"
+            showSockAddr (v6 (0xfe80, 0, 0, 0, 0, 0, 0, 1)) `shouldBe` "fe80::1"
+            showSockAddr (v6 (0x2001, 0xdb8, 0, 0, 0, 0, 0, 0)) `shouldBe` "2001:db8::"
+            showSockAddr (v6 (0x2001, 0xdb8, 0, 1, 1, 1, 1, 1)) `shouldBe` "2001:db8:0:1:1:1:1:1"
+            showSockAddr (v6 (0x2001, 0, 0, 1, 0, 0, 0, 1)) `shouldBe` "2001:0:0:1::1"
+            showSockAddr (v6 (0x2001, 0xdb8, 0, 0, 1, 0, 0, 1)) `shouldBe` "2001:db8::1:0:0:1"
+            showSockAddr (v6 (0x2001, 0xdb8, 0xABCD, 0, 0, 0, 0, 1)) `shouldBe` "2001:db8:abcd::1"
+        it "appends non-zero scope ID" $
+            showSockAddr (SockAddrInet6 80 0 (tupleToHostAddress6 (0xfe80, 0, 0, 0, 0, 0, 0, 0x1234)) 4)
+                `shouldBe` "fe80::1234%4"
+        it "does not expose Unix domain socket path" $
+            showSockAddr (SockAddrUnix "/tmp/sock") `shouldBe` "unknownSocket"
+    describe "showSockAddrBS" $ do
+        it "agrees with showSockAddr" $
+            showSockAddrBS (v4 (192, 0, 2, 1)) `shouldBe` "192.0.2.1"
+        it "does not expose Unix domain socket path" $
+            showSockAddrBS (SockAddrUnix "/tmp/sock") `shouldBe` "unknownSocket"
