diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,7 @@
+# Revision history for lipstick-brick
+
+## [0.1.0.0] - 2026-11-09
+
+First version: `blockImage` and `blockWidget` show lipstick blocks in vty
+and brick applications; `profileForColorMode` maps vty colour modes to
+lipstick colour profiles.
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Andrea Bedini
+
+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.
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,38 @@
+# lipstick-brick
+
+Show [lipstick](https://hackage.haskell.org/package/lipstick) blocks in
+[brick](https://hackage.haskell.org/package/brick) and
+[vty](https://hackage.haskell.org/package/vty) applications, so the styles,
+tables, lists and trees made with lipstick can be used in terminal user
+interfaces.
+
+```haskell
+{-# LANGUAGE OverloadedStrings #-}
+import Brick (Widget)
+import Lipstick
+import Lipstick.Brick
+
+ui :: Widget ()
+ui = blockWidget defaultRenderEnv (render (border roundedBorder <> padding 1 2) "Hello")
+```
+
+- `blockWidget` lays a block out in the width brick gives it. The widget is
+  greedy horizontally when the block grows (`fillWidth`), and fixed
+  otherwise.
+- `blockImage` gives a vty image of a block, laid out in the width of the
+  `RenderEnv`.
+- `profileForColorMode` gives the lipstick colour profile matching a vty
+  output's colour mode, so colours are resolved for what the terminal can
+  show:
+
+  ```haskell
+  env = defaultRenderEnv {envProfile = profileForColorMode (outputColorMode (outputIface vty))}
+  ```
+
+vty measures text with its own width tables, which can disagree with
+lipstick's for some emoji; layouts made of such characters may be off by a
+column.
+
+## Licence
+
+MIT, see [LICENSE](https://github.com/andreabedini/lipstick/blob/main/lipstick-brick/LICENSE).
diff --git a/lipstick-brick.cabal b/lipstick-brick.cabal
new file mode 100644
--- /dev/null
+++ b/lipstick-brick.cabal
@@ -0,0 +1,55 @@
+cabal-version:   3.12
+name:            lipstick-brick
+version:         0.1.0.0
+synopsis:        Show lipstick blocks in brick and vty applications
+description:
+  Turn lipstick blocks into vty images and brick widgets, so styles, tables,
+  lists and trees made with lipstick can be shown in terminal user interfaces.
+category:        Console, User Interfaces
+license:         MIT
+license-file:    LICENSE
+author:          Andrea Bedini
+maintainer:      andrea@bedini.au
+homepage:        https://github.com/andreabedini/lipstick
+bug-reports:     https://github.com/andreabedini/lipstick/issues
+build-type:      Simple
+tested-with:     GHC == 9.10.3 || == 9.12.2 || == 9.14.1
+extra-doc-files:
+    CHANGELOG.md
+    README.md
+
+source-repository head
+    type:     git
+    location: https://github.com/andreabedini/lipstick.git
+    subdir:   lipstick-brick
+
+common warnings
+    ghc-options: -Wall -Wcompat -Wincomplete-uni-patterns -Wincomplete-record-updates
+
+library
+    import:           warnings
+    hs-source-dirs:   src
+    exposed-modules:  Lipstick.Brick
+    build-depends:
+        base >=4.20 && <4.23,
+        text ^>=2.1,
+        lipstick ^>=0.1.0.0,
+        vty >=6.2 && <6.7,
+        brick >=2.7 && <3.1,
+        microlens >=0.4 && <0.6
+    default-language: GHC2024
+
+test-suite lipstick-brick-test
+    import:           warnings
+    type:             exitcode-stdio-1.0
+    main-is:          Main.hs
+    hs-source-dirs:   test
+    build-depends:
+        base,
+        lipstick,
+        lipstick-brick,
+        brick,
+        vty,
+        tasty ^>=1.5,
+        tasty-hunit ^>=0.10
+    default-language: GHC2024
diff --git a/src/Lipstick/Brick.hs b/src/Lipstick/Brick.hs
new file mode 100644
--- /dev/null
+++ b/src/Lipstick/Brick.hs
@@ -0,0 +1,89 @@
+-- | Show lipstick blocks in brick and vty applications.
+--
+-- > ui :: Widget ()
+-- > ui = blockWidget defaultRenderEnv (render (border roundedBorder <> padding 1 2) "Hello")
+--
+-- Widgets lay their block out in the width brick gives them, and take all
+-- of it ('Greedy') when the block grows. Colours are resolved for the
+-- 'RenderEnv' (light or dark background, profile) and handed to vty, which
+-- downsamples them further if its output needs it. 'profileForColorMode'
+-- gives the profile matching a vty output.
+--
+-- vty measures text with its own width tables, which can disagree with
+-- lipstick's for some emoji; layouts made of such characters may be off by
+-- a column.
+--
+-- vty has a single underline style and no underline colour: curly, double,
+-- dotted and dashed underlines become plain ones, and underline colours are
+-- dropped.
+module Lipstick.Brick
+  ( blockWidget
+  , blockImage
+  , profileForColorMode
+  ) where
+
+import Brick.Types (Size (..), Widget (..), availWidthL, getContext)
+import Brick.Widgets.Core (raw)
+import Lens.Micro ((^.))
+import Data.Maybe (fromMaybe)
+import Graphics.Vty qualified as V
+import Lipstick (Attrs (..), Block, ColorProfile (..), RenderEnv (..), TermColor (..), Underline (..), blockGrows, escapeUri, layout, resolveColor)
+import Lipstick.Grid (Span (..), gridLines, lineSpans)
+
+-- | A widget showing a block, laid out in the width brick makes available
+-- (whatever the environment's 'envWidth'). It is 'Greedy' horizontally
+-- when the block grows (see 'Lipstick.fillWidth'), and 'Fixed' otherwise.
+blockWidget :: RenderEnv -> Block -> Widget n
+blockWidget env b = Widget policy Fixed $ do
+  ctx <- getContext
+  render (raw (blockImage env {envWidth = Just (ctx ^. availWidthL)} b))
+  where
+    policy = if blockGrows b then Greedy else Fixed
+
+-- | A vty image of a block laid out in the environment's 'envWidth', as
+-- wide and tall as the laid-out block (but see the note on widths above).
+-- vty has no zero-width images, so a block 0 columns wide gives
+-- 'V.emptyImage' whatever its height.
+blockImage :: RenderEnv -> Block -> V.Image
+blockImage env b = V.vertCat (map line (gridLines (layout (envWidth env) b)))
+  where
+    line l = V.horizCat [V.text' (attr env (spanAttrs s)) (spanText s) | s <- lineSpans l]
+
+-- | The profile to resolve colours for, given a vty output's colour mode.
+profileForColorMode :: V.ColorMode -> ColorProfile
+profileForColorMode = \case
+  V.NoColor -> Ascii
+  V.ColorMode8 -> ANSI16
+  V.ColorMode16 -> ANSI16
+  V.ColorMode240 _ -> ANSI256
+  V.FullColor -> TrueColor
+
+attr :: RenderEnv -> Attrs -> V.Attr
+attr env a
+  | profile == NoTTY = V.defAttr
+  | otherwise =
+      withColor V.withForeColor (attrFg a)
+        . withColor V.withBackColor (attrBg a)
+        . withLink (attrLink a)
+        $ foldl V.withStyle V.defAttr styles
+  where
+    profile = envProfile env
+    withColor f c x = maybe x (f x . vtyColor) (c >>= resolveColor profile (envDarkBackground env))
+    withLink url x = maybe x (V.withURL x . escapeUri) url
+    on f = fromMaybe False (f a)
+    styles =
+      [V.bold | on attrBold]
+        ++ [V.dim | on attrFaint]
+        ++ [V.italic | on attrItalic]
+        ++ [V.blink | on attrBlink]
+        ++ [V.reverseVideo | on attrReverse]
+        ++ [V.strikethrough | on attrStrikethrough]
+        ++ [V.underline | maybe False (/= NoUnderline) (attrUnderline a)]
+
+vtyColor :: TermColor -> V.Color
+vtyColor = \case
+  TC16 n -> V.ISOColor n
+  TC256 n
+    | n < 16 -> V.ISOColor n
+    | otherwise -> V.Color240 (n - 16)
+  TCRGB r g b -> V.RGBColor r g b
diff --git a/test/Main.hs b/test/Main.hs
new file mode 100644
--- /dev/null
+++ b/test/Main.hs
@@ -0,0 +1,64 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module Main (main) where
+
+import Brick.Main (renderWidget)
+import Brick.Widgets.Core (raw)
+import Brick.Types (Size (..), Widget, hSize)
+import Graphics.Vty qualified as V
+import Lipstick
+import Lipstick.Brick
+import Test.Tasty
+import Test.Tasty.HUnit
+
+main :: IO ()
+main =
+  defaultMain $
+    testGroup
+      "lipstick-brick"
+      [ testCase "image has the block's size" $ do
+          let b = render (border roundedBorder <> padding 1 2 <> width 20) "Hello, brick"
+              img = blockImage defaultRenderEnv b
+          (V.imageWidth img, V.imageHeight img) @?= (blockWidth b, blockHeight b)
+      , testCase "tables keep their size" $ do
+          let b = table (width 30) ["A", "B"] [["one", "two three four five"]]
+              img = blockImage defaultRenderEnv b
+          (V.imageWidth img, V.imageHeight img) @?= (blockWidth b, blockHeight b)
+      , testCase "zero-width blocks are empty" $
+          blockImage defaultRenderEnv (render (paddingTop 2) "") @?= V.emptyImage
+      , testCase "bold, colours and links become vty attributes" $ do
+          let img = blockImage defaultRenderEnv (render (bold <> fg red <> bg "#102030" <> link "https://x.y") "a")
+              expected =
+                V.defAttr
+                  `V.withStyle` V.bold
+                  `V.withForeColor` V.ISOColor 1
+                  `V.withBackColor` V.RGBColor 16 32 48
+                  `V.withURL` "https://x.y"
+          img @?= V.text' expected "a"
+      , testCase "link controls are percent-encoded" $
+          blockImage defaultRenderEnv (render (link "http://x\ESC\\ y") "a")
+            @?= V.text' (V.defAttr `V.withURL` "http://x%1B\\%20y") "a"
+      , testCase "256 colours" $
+          blockImage defaultRenderEnv (render (fg "200") "a") @?= V.text' (V.defAttr `V.withForeColor` V.Color240 184) "a"
+      , testCase "light and dark backgrounds" $ do
+          let b = render (fg (lightDark "1" "2")) "a"
+          blockImage defaultRenderEnv {envDarkBackground = False} b @?= V.text' (V.defAttr `V.withForeColor` V.ISOColor 1) "a"
+          blockImage defaultRenderEnv b @?= V.text' (V.defAttr `V.withForeColor` V.ISOColor 2) "a"
+      , testCase "a block that grows is a greedy widget" $ do
+          hSize (blockWidget defaultRenderEnv (render fillWidth "x") :: Widget ()) @?= Greedy
+          hSize (blockWidget defaultRenderEnv (render mempty "x") :: Widget ()) @?= Fixed
+      , testCase "a widget takes the width brick gives it when it grows" $
+          map V.imageWidth (V.picLayers (shown (render (fillWidth <> bg red) "x") (12, 3))) @?= [12]
+      , testCase "a widget wraps its text to the width brick gives it" $ do
+          let b = text "the quick brown fox"
+              at env = V.picLayers (renderWidget Nothing [raw (blockImage env b) :: Widget ()] (10, 5))
+          V.picLayers (shown b (10, 5)) @?= at defaultRenderEnv {envWidth = Just 10}
+          assertBool "laid out at its natural width it would be cut, not wrapped" $
+            V.picLayers (shown b (10, 5)) /= at defaultRenderEnv
+      , testCase "colour modes" $ do
+          profileForColorMode V.FullColor @?= TrueColor
+          profileForColorMode (V.ColorMode240 240) @?= ANSI256
+          profileForColorMode V.NoColor @?= Ascii
+      ]
+  where
+    shown b region = renderWidget Nothing [blockWidget defaultRenderEnv b :: Widget ()] region
