diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,11 @@
+# Changelog for scanf
+
+See [latest version of this changelog](https://github.com/Lysxia/scanf/blob/master/CHANGELOG.md).
+
+## 0.2.0.0 - 2026-11-07
+
+- Add scansets: `%[abc]` matches a string with only the characters `a`, `b`, `c`.
+
+## 0.1.0.0 - 2018-03-18
+
+- Create scanf
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -1,5 +1,7 @@
-# Haskell scanf [![Build Status](https://travis-ci.org/Lysxia/scanf.svg?branch=master)](https://travis-ci.org/Lysxia/scanf)
+# Haskell scanf
 
+[![Build Status](https://github.com/Lysxia/scanf/workflows/Haskell-CI/badge.svg)](https://github.com/Lysxia/workflows/actions?query=branch%3Amaster) [![Hackage](https://img.shields.io/hackage/v/scanf.svg)](https://hackage.haskell.org/package/scanf)
+
 A lightweight library for one-off parsing and printing.
 
 ```haskell
@@ -19,12 +21,13 @@
 
 With quasiquotes, the following conversion strings are supported:
 
-- `%d`: signed integer (`Int`)
-- `%l`: signed integer (`Integer`, unbounded)
-- `%f`: floating point (`Double`)
-- `%s`: string of non-space characters (`String`)
-- `%c`: single character (`Char`)
-- `%%`: parse/print a literal percent character
+- `%d`    : signed integer (`Int`)
+- `%l`    : signed integer (`Integer`, unbounded)
+- `%f`    : floating point (`Double`)
+- `%s`    : string of non-space characters (`String`)
+- `%[...]`: [Scanset](https://www.man7.org/linux/man-pages/man3/sscanf.3.html) (`String`)
+- `%c`    : single character (`Char`)
+- `%%`    : parse/print a literal percent character
 
 N.B.: in `scanf`, spaces in the format string match any number of whitespace
 character until the next nonspace character.
diff --git a/scanf.cabal b/scanf.cabal
--- a/scanf.cabal
+++ b/scanf.cabal
@@ -1,5 +1,5 @@
 name:                scanf
-version:             0.1.0.0
+version:             0.2.0.0
 synopsis:            Easy and type-safe format strings for parsing and printing
 description:
 
@@ -15,9 +15,9 @@
 copyright:           2018 Li-yao Xia
 category:            Text
 build-type:          Simple
-extra-source-files:  README.md
+extra-source-files:  README.md, CHANGELOG.md
 cabal-version:       >=1.10
-tested-with:         GHC == 7.10.3, GHC == 8.0.2, GHC == 8.2.2, GHC == 8.4.1
+tested-with:         GHC == 8.0.2, GHC == 8.4.4, GHC == 9.12.2
 
 library
   hs-source-dirs:      src
@@ -26,7 +26,8 @@
     Text.Scanf.Internal
     Text.Scanf.TH
   build-depends:
-    template-haskell,
+    template-haskell < 3,
+    containers >= 0.6.6 && < 1,
     base >= 4.7 && < 5
   ghc-options:         -Wall
   default-language:    Haskell2010
diff --git a/src/Text/Scanf.hs b/src/Text/Scanf.hs
--- a/src/Text/Scanf.hs
+++ b/src/Text/Scanf.hs
@@ -5,6 +5,7 @@
 module Text.Scanf
   ( -- * Main definitions
     scanf
+  , scanf'
   , printf
   , (:+)(..)
   , fmt
@@ -17,6 +18,7 @@
   , int
   , double
   , string
+  , scanset
   , char
   ) where
 
diff --git a/src/Text/Scanf/Internal.hs b/src/Text/Scanf/Internal.hs
--- a/src/Text/Scanf/Internal.hs
+++ b/src/Text/Scanf/Internal.hs
@@ -6,6 +6,7 @@
 
 import Data.Char (isSpace)
 import Data.List (stripPrefix)
+import Data.Set  (Set)
 
 -- | A pretty pair type to build lists with values of different types. 
 -- Remember to close lists with @()@.
@@ -26,7 +27,8 @@
   Constant :: String -> Format t -> Format t
   Whitespace :: String -> Format t -> Format t
   Readable :: (Read a, Show a) => Format t -> Format (a :+ t)
-  String :: Format t -> Format (String :+ t)  -- whitespace delimited
+  String :: Format t -> Format (String :+ t)                       -- whitespace delimited
+  Scanset :: Set Char -> Bool -> Format t -> Format (String :+ t)  -- custom delimiter
   Char :: Format t -> Format (Char :+ t)
 
 deriving instance Show (Format t)
@@ -96,6 +98,11 @@
 string :: Format t -> Format (String :+ t)
 string = String
 
+-- | Format a scanset 'String', given a set of characters to include if second argument
+-- | is @True@, excluding them otherwise.
+scanset :: Set Char -> Bool -> Format t -> Format (String :+ t)
+scanset = Scanset
+
 -- | Format a 'Char'.
 char :: Format t -> Format (Char :+ t)
 char = Char
@@ -113,6 +120,9 @@
 readsFormat (String f) s = do
   let (s0, s') = break isSpace s
   readmap (s0 :+) (readsFormat f) s'
+readsFormat (Scanset set inc f) s = do
+  let (s0, s') = break (if inc then not . flip elem set else flip elem set) s
+  readmap (s0 :+) (readsFormat f) s'
 readsFormat (Char f) s = do
   c : s' <- pure s
   readmap (c :+) (readsFormat f) s'
@@ -132,6 +142,12 @@
   (r, _) : _ <- pure (readsFormat f s)
   pure r
 
+-- | Same as scanf, but returns the unparsed string as well.
+scanf' :: Format t -> String -> Maybe (t, String)
+scanf' f s = do
+  x : _ <- pure (readsFormat f s)
+  pure x
+
 -- | Print a string according to a format string.
 --
 -- @
@@ -142,6 +158,7 @@
 printf (Constant z f) t = z ++ printf f t
 printf (Readable f) (a :+ t) = show a ++ printf f t
 printf (String f) (s :+ t) = s ++ printf f t
+printf (Scanset _ _ f) (s :+ t) = s ++ printf f t
 printf (Char f) (c :+ t) = c : printf f t
 printf (Whitespace s f) t = s ++ printf f t
 printf Empty _ = ""
diff --git a/src/Text/Scanf/TH.hs b/src/Text/Scanf/TH.hs
--- a/src/Text/Scanf/TH.hs
+++ b/src/Text/Scanf/TH.hs
@@ -10,23 +10,102 @@
 
 import Text.Scanf.Internal
 
+import Data.Set (Set, empty, insert, singleton)
+
+import Numeric (showHex)
+
 formatString :: String -> Q Exp
-formatString "" = [|Empty|]
-formatString ('%' : s1) =
+formatString ('\\' : s1) = formatString' $ escape s1
+formatString s1          = formatString'          s1
+
+formatString' :: String -> Q Exp
+formatString' ""         = [|Empty|]
+formatString' ('%' : s1) =
   case s1 of
-    'c' : s' -> [|char $(formatString s')|]
-    'd' : s' -> [|int $(formatString s')|]
-    'f' : s' -> [|double $(formatString s')|]
+    'c' : s' -> [|char    $(formatString s')|]
+    'd' : s' -> [|int     $(formatString s')|]
+    'f' : s' -> [|double  $(formatString s')|]
     'l' : s' -> [|integer $(formatString s')|]
-    's' : s' -> [|string $(formatString s')|]
+    's' : s' -> [|string  $(formatString s')|]
+    '[' : s' -> mkScanset s'
     '%' : s' -> [|constant "%" $(formatString s')|]
     _ -> error "Invalid format string"
-formatString s@(c : _) | isSpace c =
+  where
+    mkScanset :: String -> Q Exp
+    mkScanset s' = [|scanset set inc $(formatString s'')|]
+      where
+        set :: Set Char
+        inc :: Bool
+        s'' :: String
+
+        ((set, s''), inc) = parseScanset s'
+
+        parseScanset :: String -> ((Set Char, String), Bool)
+        parseScanset []                = error "Unterminated scanset"
+        parseScanset ['^', ']']        = error "Unterminated scanset"
+        parseScanset ('^' : ']' : t'') = (parseScanset' (singleton ']') t'', False)
+        parseScanset ('^' :       t'') = (parseScanset' empty           t'', False)
+        parseScanset (']' :       t'') = (parseScanset' (singleton ']') t'', True)
+        parseScanset t''               = (parseScanset' empty           t'', True)
+
+        parseScanset' :: Set Char -> String -> (Set Char, String)
+        parseScanset' s ('\\' : t'') = parseScanset'' s $ escape t''
+        parseScanset' s t''          = parseScanset'' s   t''
+
+        parseScanset'' :: Set Char -> String -> (Set Char, String)
+        parseScanset'' _ []                        = error "Unterminated scanset"
+        parseScanset'' s (']' :               t'') = (s, t'')
+        parseScanset'' s (c1  : '-'  : ']'  : t'') = (insert '-' $ insert c1 s , t'')
+        parseScanset'' s (c1  : '-'  : c2   : t'') =
+          case c2 of
+            '\\' -> parseScanset' (foldr insert s [c1..c2'])  t'''
+            _    -> parseScanset' (foldr insert s [c1..c2])   t''
+          where
+            (c2',t''') = escape' t''
+        parseScanset'' s (c   :               t'') = parseScanset' (insert c s)               t''
+
+formatString' s@(c : _) | isSpace c =
   let (s0, s') = span isSpace s
   in [|whitespace $(lift s0) $(formatString s')|]
-formatString s =
-  let (s0, s') = break (\c -> isSpace c || c == '%') s
+formatString' s =
+  let (s0, s') = break (\c -> isSpace c || c == '%' || c == '\\') s
   in [|constant $(lift s0) $(formatString s')|]
+
+-- Escapes the first character(s) in the input string.
+escape :: String -> String
+escape s = c : s'
+  where (c,s') = escape' s
+
+escape' :: String -> (Char,String)
+escape' ""      = error "Can't escape an empty string"
+escape' (c : s) =
+  case c of
+    '0'  -> (toEnum 0x00 , s)
+    'a'  -> (toEnum 0x07 , s)
+    'b'  -> (toEnum 0x08 , s)
+    'e'  -> (toEnum 0x1b , s)
+    'f'  -> (toEnum 0x0c , s)
+    'n'  -> ('\n'        , s)
+    'r'  -> (toEnum 0x0d , s)
+    't'  -> ('\t'        , s)
+    'v'  -> (toEnum 0x0b , s)
+    'x'  -> escapeHex      s
+    '\'' -> ('\''        , s)
+    '"'  -> ('\"'        , s)
+    '?'  -> (toEnum 0x3f , s)
+    '\\' -> ('\\'        , s)
+    _    -> error $ "Unknown escape sequence " ++ "(0x" ++ showHex (fromEnum c) "): '" ++ [c] ++ "'"
+  where
+    -- Replaces two hex digits with a character of that value.
+    escapeHex :: String -> (Char,String)
+    escapeHex (c1 : c0 : t) = ((toEnum $ 16 * fromHexDigit c1 + fromHexDigit c0) , t)
+      where
+        fromHexDigit :: Char -> Int
+        fromHexDigit hc | '0' <= hc && hc <= '9' = fromEnum hc - fromEnum '0'
+                        | 'A' <= hc && hc <= 'F' = 10 + fromEnum hc - fromEnum 'A'
+                        | 'a' <= hc && hc <= 'f' = 10 + fromEnum hc - fromEnum 'a'
+                        | otherwise = error $ "Invalid hex digit in \\x sequence: " ++ [hc]
+    escapeHex _ = error "Missing 2-digit hexadecimal number following \\x."
 
 -- | Parse a typed 'Format' string.
 --
diff --git a/test/example.hs b/test/example.hs
--- a/test/example.hs
+++ b/test/example.hs
@@ -20,11 +20,69 @@
   scanf [fmt|%s %s|] " a b"
     =? Just ("" :+ "a" :+ ())
 
+  scanf [fmt|%[ ab]|] " a b"
+    =? Just (" a b" :+ ())
+
+  scanf [fmt|%[^ ab]|] "c a b"
+    =? Just ("c" :+ ())
+
+  scanf [fmt|%[]]|] "]]]]]]]a]]]]]"
+    =? Just ("]]]]]]]" :+ ())
+
+  scanf [fmt|%[^]]|] "aaaaaaa]aaaaa"
+    =? Just ("aaaaaaa" :+ ())
+
+  scanf [fmt|%[a-d]|] "aabaabcbaabcdcbaabcdedcba"
+    =? Just ("aabaabcbaabcdcbaabcd" :+ ())
+
+  scanf [fmt|%[^\n]|] "aabaabcbaabc\ndcbaabcdedcba"
+    =? Just ("aabaabcbaabc" :+ ())
+
   scanf [fmt|%%|] "%"
     =? Just ()
 
   scanf [fmt|a a|] "a  a b"
     =? Just ()
+
+  scanf [fmt|\n\n\n%[\x08\x09]|] "\n\n\n\x08\x09\x09"
+    =? Just ("\x08\x09\x09" :+ ())
+
+  scanf [fmt|\n\n\n%[\x08\x09]|] "\n\n\n\x08\x09\x09"
+    =? Just ("\x08\x09\x09" :+ ())
+
+  scanf [fmt|%[^\\\'\a]|] "abcdef\\\'"
+    =? Just ("abcdef" :+ ())
+
+  scanf [fmt|\a\b\t\x6c\x13%[^\b]|] "\a\b\tl\x13 1234567890\x12\b"
+    =? Just (" 1234567890\x12" :+ ())
+
+  scanf [fmt|\a\b\t\x6c\x13 %[^\b]|] "\a\b\tl\x13 \n\n\t\x09\x020 1234567890\x12\b"
+    =? Just ("1234567890\x12" :+ ())
+
+  scanf [fmt|This is an alphanumeric string: %[a-z0-9]|] "This is an alphanumeric string: abcdefghijklmnopqrstuv0123456789"
+    =? Just ("abcdefghijklmnopqrstuv0123456789" :+ ())
+
+  scanf [fmt|test: %[\x00-\x10]|] "test: \x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10\x11"
+    =? Just ("\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10" :+ ())
+
+  scanf [fmt|test: %[\\-\\]|] "test: \\\\\\\\\\\\\a"
+    =? Just ("\\\\\\\\\\\\" :+ ())
+
+  scanf [fmt|test: %[\t-\n]|] "test: \t\n\t\n\t\n\t"
+    =? Just ("" :+ ())
+
+  scanf [fmt|test:%[ ]%[\t-\n]:test2|] "test: \t\n\t\n\t\n\t:test2"
+    =? Just (" " :+ "\t\n\t\n\t\n\t" :+ ())
+
+  scanf [fmt|%[%b-a]|] "%%%ababbaa"
+    =? Just ("%%%" :+ ())
+
+  scanf [fmt|%[^%b-a]|] "ababbaa%%%"
+    =? Just ("ababbaa" :+ ())
+
+  scanf [fmt|%[\x2C\x2b]|] "\x2C\x2c\x2B\x2b"
+    =? Just ("\x2c\x2C\x2b\x2B" :+ ())
+
 
   let s0 = "10-11-12   Fri  4.3C"
       f0 = [fmt|%d-%d-%l %s %f %c|]
