Repository: charmbracelet/bubbletea
Pull request #1295 (2025-01-23 17:30:18)

(v2) feat: add Frame type and remove cursor commands
This commit introduces a new `Frame` type that represents a single frame of the program's output. The `Frame` type contains the frame's content and cursor settings. The cursor settings include the cursor's position, style, blink, and visibility.

It also removes the cursor commands from the `tea` package. The cursor commands are now part of the `Frame` type. The cursor commands were removed because they were not idiomatic and were not flexible enough to support additional cursor settings.

API:
```go
// Model contains the program's state as well as its core functions.
type Model interface {
	// Init is the first function that will be called. It returns an optional
	// initial command. To not perform an initial command return nil.
	Init() (Model, Cmd)

	// Update is called when a message is received. Use it to inspect messages
	// and, in response, update the model and/or send a command.
	Update(Msg) (Model, Cmd)

	// View renders the program's UI, which is just a [fmt.Stringer]. The view
	// is rendered after every Update.
	// The main model can return a [Frame] to set the cursor position and
	// style.
	View() fmt.Stringer
}

// Cursor represents a cursor on the terminal screen.
type Cursor struct {
	// Position is a [Position] that determines the cursor's position on the
	// screen relative to the top left corner of the frame.
	Position Position

	// Color is a [color.Color] that determines the cursor's color.
	Color color.Color

	// Shape is a [CursorShape] that determines the cursor's style.
	Shape CursorShape

	// Blink is a boolean that determines whether the cursor should blink.
	Blink bool
}

// NewCursor returns a new cursor with the default settings and the given
// position.
func NewCursor(x, y int) *Cursor {
	return &Cursor{
		Position: Position{X: x, Y: y},
		Color:    nil,
		Shape:    CursorBlock,
		Blink:    true,
	}
}

// Frame represents a single frame of the program's output.
type Frame struct {
	// Content contains the frame's content. This is the only required field.
	// It should be a string of text and ANSI escape codes.
	Content string

	// Cursor contains cursor settings for the frame. If nil, the cursor will
	// be hidden.
	Cursor *Cursor
}

// NewFrame creates a new frame with the given content.
func NewFrame(content string) Frame {
	return Frame{Content: content}
}

// String implements the fmt.Stringer interface for [Frame].
func (f Frame) String() string {
	return f.Content
}

```

Supersedes: https://github.com/charmbracelet/bubbletea/pull/1293


Patch:
diff --git a/cursed_renderer.go b/cursed_renderer.go
index 9ef25c4..05bc73d 100644
--- a/cursed_renderer.go
+++ b/cursed_renderer.go
@@ -1,22 +1,25 @@
 package tea
 
 import (
+	"fmt"
 	"io"
 	"strings"
 	"sync"
 
 	"github.com/charmbracelet/colorprofile"
+	"github.com/charmbracelet/x/ansi"
 	"github.com/charmbracelet/x/cellbuf"
 )
 
 type cursedRenderer struct {
 	w             io.Writer
 	scr           *cellbuf.Screen
-	lastFrame     *string
+	lastFrame     *Frame
 	term          string // the terminal type $TERM
 	width, height int
 	mu            sync.Mutex
 	profile       colorprofile.Profile
+	cursor        Cursor
 	altScreen     bool
 	cursorHidden  bool
 	hardTabs      bool // whether to use hard tabs to optimize cursor movements
@@ -44,20 +47,48 @@ func (s *cursedRenderer) close() (err error) {
 func (s *cursedRenderer) flush() error {
 	s.mu.Lock()
 	defer s.mu.Unlock()
+	if s.lastFrame != nil && s.lastFrame.Cursor != nil {
+		cur := s.lastFrame.Cursor
+		s.scr.MoveTo(cur.Position.X, cur.Position.Y)
+		s.cursor.Position = cur.Position
+
+		if cur.Shape != s.cursor.Shape || cur.Blink != s.cursor.Blink {
+			cursorStyle := encodeCursorStyle(cur.Shape, cur.Blink)
+			io.WriteString(s.w, ansi.SetCursorStyle(cursorStyle)) //nolint:errcheck
+			s.cursor.Shape = cur.Shape
+			s.cursor.Blink = cur.Blink
+		}
+		if cur.Color != s.cursor.Color {
+			seq := ansi.ResetCursorColor
+			if cur.Color != nil {
+				seq = ansi.SetCursorColor(cur.Color)
+			}
+			io.WriteString(s.w, seq) //nolint:errcheck
+			s.cursor.Color = cur.Color
+		}
+	}
 	s.scr.Render()
 	return nil
 }
 
 // render implements renderer.
-func (s *cursedRenderer) render(frame string) {
+func (s *cursedRenderer) render(frame fmt.Stringer) {
 	s.mu.Lock()
 	defer s.mu.Unlock()
 
-	if s.lastFrame != nil && frame == *s.lastFrame {
+	var f Frame
+	switch frame := frame.(type) {
+	case Frame:
+		f = frame
+	default:
+		f.Content = frame.String()
+	}
+
+	if s.lastFrame != nil && f == *s.lastFrame {
 		return
 	}
 
-	s.lastFrame = &frame
+	s.lastFrame = &f
 	if !s.altScreen {
 		// Inline mode resizes the screen based on the frame height and
 		// terminal width. This is because the frame height can change based on
@@ -65,12 +96,18 @@ func (s *cursedRenderer) render(frame string) {
 		// of items, the height of the frame will be the number of items in the
 		// list. This is different from the alt screen buffer, which has a
 		// fixed height and width.
-		frameHeight := strings.Count(frame, "\n") + 1
+		frameHeight := strings.Count(f.Content, "\n") + 1
 		s.scr.Resize(s.width, frameHeight)
 	}
 
 	if ctx := s.scr.DefaultWindow(); ctx != nil {
-		ctx.SetContent(frame)
+		ctx.SetContent(f.Content)
+	}
+
+	if f.Cursor == nil {
+		hideCursor(s)
+	} else {
+		showCursor(s)
 	}
 }
 
@@ -138,7 +175,7 @@ func (s *cursedRenderer) exitAltScreen() {
 	s.altScreen = false
 	s.scr.ExitAltScreen()
 	s.scr.SetRelativeCursor(!s.altScreen)
-	s.scr.Resize(s.width, strings.Count(*s.lastFrame, "\n")+1)
+	s.scr.Resize(s.width, strings.Count((*s.lastFrame).Content, "\n")+1)
 	repaint(s)
 	s.mu.Unlock()
 }
@@ -146,17 +183,25 @@ func (s *cursedRenderer) exitAltScreen() {
 // showCursor implements renderer.
 func (s *cursedRenderer) showCursor() {
 	s.mu.Lock()
+	showCursor(s)
+	s.mu.Unlock()
+}
+
+func showCursor(s *cursedRenderer) {
 	s.cursorHidden = false
 	s.scr.ShowCursor()
-	s.mu.Unlock()
 }
 
 // hideCursor implements renderer.
 func (s *cursedRenderer) hideCursor() {
 	s.mu.Lock()
+	hideCursor(s)
+	s.mu.Unlock()
+}
+
+func hideCursor(s *cursedRenderer) {
 	s.cursorHidden = true
 	s.scr.HideCursor()
-	s.mu.Unlock()
 }
 
 // insertAbove implements renderer.
@@ -166,13 +211,6 @@ func (s *cursedRenderer) insertAbove(lines string) {
 	s.mu.Unlock()
 }
 
-// moveTo implements renderer.
-func (s *cursedRenderer) moveTo(x, y int) {
-	s.mu.Lock()
-	s.scr.MoveTo(x, y)
-	s.mu.Unlock()
-}
-
 func (s *cursedRenderer) repaint() {
 	s.mu.Lock()
 	repaint(s)
diff --git a/cursor.go b/cursor.go
index 254dff4..2edb6ec 100644
--- a/cursor.go
+++ b/cursor.go
@@ -2,57 +2,22 @@ package tea
 
 import "image"
 
+// Position represents a position in the terminal.
+type Position image.Point
+
 // CursorPositionMsg is a message that represents the terminal cursor position.
-type CursorPositionMsg image.Point
+type CursorPositionMsg Position
 
-// CursorStyle is a style that represents the terminal cursor.
-type CursorStyle int
+// CursorShape represents a terminal cursor shape.
+type CursorShape int
 
-// Cursor styles.
+// Cursor shapes.
 const (
-	CursorBlock CursorStyle = iota
+	CursorBlock CursorShape = iota
 	CursorUnderline
 	CursorBar
 )
 
-// setCursorStyle is an internal message that sets the cursor style. This matches the
-// ANSI escape sequence values for cursor styles. This includes:
-//
-//	0: Blinking block
-//	1: Blinking block (default)
-//	2: Steady block
-//	3: Blinking underline
-//	4: Steady underline
-//	5: Blinking bar (xterm)
-//	6: Steady bar (xterm)
-type setCursorStyle int
-
-// SetCursorStyle is a command that sets the terminal cursor style. Steady
-// determines if the cursor should blink or not.
-func SetCursorStyle(style CursorStyle, blink bool) Cmd {
-	// We're using the ANSI escape sequence values for cursor styles.
-	// We need to map both [style] and [steady] to the correct value.
-	style = (style * 2) + 1
-	if !blink {
-		style++
-	}
-	return func() Msg {
-		return setCursorStyle(style)
-	}
-}
-
-// setCursorPosMsg represents a message to set the cursor position.
-type setCursorPosMsg image.Point
-
-// SetCursorPosition sets the cursor position to the specified relative
-// coordinates. Using -1 for either x or y will not change the cursor position
-// for that axis.
-func SetCursorPosition(x, y int) Cmd {
-	return func() Msg {
-		return setCursorPosMsg{x, y}
-	}
-}
-
 // requestCursorPosMsg is a message that requests the cursor position.
 type requestCursorPosMsg struct{}
 
diff --git a/examples/altscreen-toggle/main.go b/examples/altscreen-toggle/main.go
index fc9829c..041e57f 100644
--- a/examples/altscreen-toggle/main.go
+++ b/examples/altscreen-toggle/main.go
@@ -50,13 +50,15 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, nil
 }
 
-func (m model) View() string {
+func (m model) View() fmt.Stringer {
+	f := tea.NewFrame("")
 	if m.suspending {
-		return ""
+		return f
 	}
 
 	if m.quitting {
-		return "Bye!\n"
+		f.Content = "Bye!\n"
+		return f
 	}
 
 	const (
@@ -71,8 +73,9 @@ func (m model) View() string {
 		mode = inlineMode
 	}
 
-	return fmt.Sprintf("\n\n  You're in %s\n\n\n", keywordStyle.Render(mode)) +
+	f.Content = fmt.Sprintf("\n\n  You're in %s\n\n\n", keywordStyle.Render(mode)) +
 		helpStyle.Render("  space: switch modes • ctrl-z: suspend • q: exit\n")
+	return f
 }
 
 func main() {
diff --git a/examples/autocomplete/main.go b/examples/autocomplete/main.go
index 316b48a..161efa7 100644
--- a/examples/autocomplete/main.go
+++ b/examples/autocomplete/main.go
@@ -125,10 +125,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, cmd
 }
 
-func (m model) View() string {
-	return fmt.Sprintf(
+func (m model) View() fmt.Stringer {
+	return tea.NewFrame(fmt.Sprintf(
 		"Pick a Charm™ repo:\n\n  %s\n\n%s\n\n",
 		m.textInput.View(),
 		m.help.View(m.keymap),
-	)
+	))
 }
diff --git a/examples/capability/main.go b/examples/capability/main.go
index 00856e0..5647629 100644
--- a/examples/capability/main.go
+++ b/examples/capability/main.go
@@ -1,6 +1,7 @@
 package main
 
 import (
+	"fmt"
 	"log"
 
 	"github.com/charmbracelet/bubbles/v2/textinput"
@@ -45,16 +46,16 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 }
 
 // View implements tea.Model.
-func (m model) View() string {
+func (m model) View() fmt.Stringer {
 	w := min(m.width, 60)
 
 	instructions := lipgloss.NewStyle().
 		Width(w).
 		Render("Query for terminal capabilities. You can enter things like 'TN', 'RGB', 'cols', and so on. This will not work in all terminals and multiplexers.")
 
-	return "\n" + instructions + "\n\n" +
+	return tea.NewFrame("\n" + instructions + "\n\n" +
 		m.input.View() +
-		"\n\nPress enter to request capability, or ctrl+c to quit."
+		"\n\nPress enter to request capability, or ctrl+c to quit.")
 }
 
 func main() {
diff --git a/examples/cellbuffer/main.go b/examples/cellbuffer/main.go
index 2d90b38..d361f00 100644
--- a/examples/cellbuffer/main.go
+++ b/examples/cellbuffer/main.go
@@ -184,8 +184,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	}
 }
 
-func (m model) View() string {
-	return m.cells.String()
+func (m model) View() fmt.Stringer {
+	return m.cells
 }
 
 func main() {
diff --git a/examples/chat/main.go b/examples/chat/main.go
index 097f28f..6377c46 100644
--- a/examples/chat/main.go
+++ b/examples/chat/main.go
@@ -107,11 +107,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, nil
 }
 
-func (m model) View() string {
-	return fmt.Sprintf(
+func (m model) View() fmt.Stringer {
+	return tea.NewFrame(fmt.Sprintf(
 		"%s%s%s",
 		m.viewport.View(),
 		gap,
 		m.textarea.View(),
-	)
+	))
 }
diff --git a/examples/colorprofile/main.go b/examples/colorprofile/main.go
index cae5b9e..68a6ac5 100644
--- a/examples/colorprofile/main.go
+++ b/examples/colorprofile/main.go
@@ -1,6 +1,7 @@
 package main
 
 import (
+	"fmt"
 	"image/color"
 	"log"
 
@@ -36,11 +37,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 }
 
 // View implements tea.Model.
-func (m model) View() string {
-	return "This will produce the wrong colors on Apple Terminal :)\n\n" +
+func (m model) View() fmt.Stringer {
+	return tea.NewFrame("This will produce the wrong colors on Apple Terminal :)\n\n" +
 		ansi.Style{}.ForegroundColor(myFancyColor).Styled("Howdy!") +
 		"\n\n" +
-		"Press any key to exit."
+		"Press any key to exit.")
 }
 
 func main() {
diff --git a/examples/composable-views/main.go b/examples/composable-views/main.go
index db477ae..4437448 100644
--- a/examples/composable-views/main.go
+++ b/examples/composable-views/main.go
@@ -3,6 +3,7 @@ package main
 import (
 	"fmt"
 	"log"
+	"strings"
 	"time"
 
 	"github.com/charmbracelet/bubbles/v2/spinner"
@@ -123,16 +124,16 @@ func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, tea.Batch(cmds...)
 }
 
-func (m mainModel) View() string {
-	var s string
+func (m mainModel) View() fmt.Stringer {
+	var s strings.Builder
 	model := m.currentFocusedModel()
 	if m.state == timerView {
-		s += lipgloss.JoinHorizontal(lipgloss.Top, focusedModelStyle.Render(fmt.Sprintf("%4s", m.timer.View())), modelStyle.Render(m.spinner.View()))
+		s.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, focusedModelStyle.Render(fmt.Sprintf("%4s", m.timer.View())), modelStyle.Render(m.spinner.View())))
 	} else {
-		s += lipgloss.JoinHorizontal(lipgloss.Top, modelStyle.Render(fmt.Sprintf("%4s", m.timer.View())), focusedModelStyle.Render(m.spinner.View()))
+		s.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, modelStyle.Render(fmt.Sprintf("%4s", m.timer.View())), focusedModelStyle.Render(m.spinner.View())))
 	}
-	s += helpStyle.Render(fmt.Sprintf("\ntab: focus next • n: new %s • q: exit\n", model))
-	return s
+	s.WriteString(helpStyle.Render(fmt.Sprintf("\ntab: focus next • n: new %s • q: exit\n", model)))
+	return &s
 }
 
 func (m mainModel) currentFocusedModel() string {
diff --git a/examples/credit-card-form/main.go b/examples/credit-card-form/main.go
index 058974b..5d75955 100644
--- a/examples/credit-card-form/main.go
+++ b/examples/credit-card-form/main.go
@@ -163,18 +163,18 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, tea.Batch(cmds...)
 }
 
-func (m model) View() string {
-	return fmt.Sprintf(
+func (m model) View() fmt.Stringer {
+	return tea.NewFrame(fmt.Sprintf(
 		` Total: $21.50:
 
- %s
- %s
+		%s
+		%s
 
- %s  %s
- %s  %s
+		%s  %s
+		%s  %s
 
- %s
-`,
+		%s
+		`,
 		inputStyle.Width(30).Render("Card Number"),
 		m.inputs[ccn].View(),
 		inputStyle.Width(6).Render("EXP"),
@@ -182,7 +182,7 @@ func (m model) View() string {
 		m.inputs[exp].View(),
 		m.inputs[cvv].View(),
 		continueStyle.Render("Continue ->"),
-	) + "\n"
+	) + "\n")
 }
 
 // nextInput focuses the next input field
diff --git a/examples/cursor-style/main.go b/examples/cursor-style/main.go
index 42d2a65..e199270 100644
--- a/examples/cursor-style/main.go
+++ b/examples/cursor-style/main.go
@@ -14,15 +14,10 @@ type model struct {
 
 func (m model) Init() (tea.Model, tea.Cmd) {
 	m.blink = true
-	return m, tea.Batch(
-		tea.ShowCursor,
-		tea.SetCursorStyle(m.style, m.blink),
-		tea.SetCursorPosition(0, 2),
-	)
+	return m, nil
 }
 
 func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	var cmd tea.Cmd
 	switch msg := msg.(type) {
 	case tea.KeyPressMsg:
 		switch msg.String() {
@@ -36,7 +31,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				m.style--
 			}
 			m.blink = !m.blink
-			cmd = tea.SetCursorStyle(m.style, m.blink)
 		case "l", "right":
 			if m.style == tea.CursorBar && !m.blink {
 				break
@@ -45,19 +39,19 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				m.style++
 			}
 			m.blink = !m.blink
-			cmd = tea.SetCursorStyle(m.style, m.blink)
 		}
 	}
-	return m, tea.Batch(
-		cmd,
-		tea.SetCursorPosition(0, 2),
-	)
+	return m, nil
 }
 
-func (m model) View() string {
-	return "Press left/right to change the cursor style, q or ctrl+c to quit." +
+func (m model) View() fmt.Stringer {
+	f := tea.NewFrame("Press left/right to change the cursor style, q or ctrl+c to quit." +
 		"\n\n" +
-		"  <- This is the cursor (a " + m.describeCursor() + ")"
+		"  <- This is the cursor (a " + m.describeCursor() + ")")
+	f.Cursor = tea.NewCursor(0, 2)
+	f.Cursor.Style = m.style
+	f.Cursor.Blink = m.blink
+	return f
 }
 
 func (m model) describeCursor() string {
diff --git a/examples/debounce/main.go b/examples/debounce/main.go
index d70863b..2e1187b 100644
--- a/examples/debounce/main.go
+++ b/examples/debounce/main.go
@@ -53,9 +53,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, nil
 }
 
-func (m model) View() string {
-	return fmt.Sprintf("Key presses: %d", m.tag) +
-		"\nTo exit press any key, then wait for one second without pressing anything."
+func (m model) View() fmt.Stringer {
+	return tea.NewFrame(fmt.Sprintf("Key presses: %d", m.tag) +
+		"\nTo exit press any key, then wait for one second without pressing anything.")
 }
 
 func main() {
diff --git a/examples/doom-fire/main.go b/examples/doom-fire/main.go
index dcbf2a8..7ec28ee 100644
--- a/examples/doom-fire/main.go
+++ b/examples/doom-fire/main.go
@@ -48,9 +48,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, nil
 }
 
-func (m model) View() string {
+func (m model) View() fmt.Stringer {
 	if m.width == 0 {
-		return "Initializing..."
+		return tea.NewFrame("Initializing...")
 	}
 
 	var s strings.Builder
@@ -76,7 +76,7 @@ func (m model) View() string {
 
 	elapsed := time.Since(m.startTime)
 	s.WriteString(whiteFg.Render("Press q or ctrl+c to quit. " + fmt.Sprintf("Elapsed: %s", elapsed.Round(time.Second))))
-	return s.String()
+	return &s
 }
 
 func (m *model) spreadFire() {
diff --git a/examples/exec/main.go b/examples/exec/main.go
index 8bb6b16..026f4a8 100644
--- a/examples/exec/main.go
+++ b/examples/exec/main.go
@@ -55,11 +55,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, nil
 }
 
-func (m model) View() string {
+func (m model) View() fmt.Stringer {
 	if m.err != nil {
-		return "Error: " + m.err.Error() + "\n"
+		return tea.NewFrame("Error: " + m.err.Error() + "\n")
 	}
-	return "Press 'e' to open your EDITOR.\nPress 'a' to toggle the altscreen\nPress 'q' to quit.\n"
+	return tea.NewFrame("Press 'e' to open your EDITOR.\nPress 'a' to toggle the altscreen\nPress 'q' to quit.\n")
 }
 
 func main() {
diff --git a/examples/file-picker/main.go b/examples/file-picker/main.go
index ca9431d..a1421a9 100644
--- a/examples/file-picker/main.go
+++ b/examples/file-picker/main.go
@@ -65,9 +65,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, cmd
 }
 
-func (m model) View() string {
+func (m model) View() fmt.Stringer {
 	if m.quitting {
-		return ""
+		return tea.NewFrame("")
 	}
 	var s strings.Builder
 	s.WriteString("\n  ")
@@ -79,7 +79,7 @@ func (m model) View() string {
 		s.WriteString("Selected file: " + m.filepicker.Styles.Selected.Render(m.selectedFile))
 	}
 	s.WriteString("\n\n" + m.filepicker.View() + "\n")
-	return s.String()
+	return &s
 }
 
 func main() {
diff --git a/examples/focus-blur/main.go b/examples/focus-blur/main.go
index 3bf30f7..0690def 100644
--- a/examples/focus-blur/main.go
+++ b/examples/focus-blur/main.go
@@ -3,6 +3,7 @@ package main
 // A simple program that handled losing and acquiring focus.
 
 import (
+	"fmt"
 	"log"
 
 	tea "github.com/charmbracelet/bubbletea/v2"
@@ -46,7 +47,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, nil
 }
 
-func (m model) View() string {
+func (m model) View() fmt.Stringer {
 	s := "Hi. Focus report is currently "
 	if m.reporting {
 		s += "enabled"
@@ -62,5 +63,5 @@ func (m model) View() string {
 			s += "This program is currently blurred!"
 		}
 	}
-	return s + "\n\nTo quit sooner press ctrl-c, or t to toggle focus reporting...\n"
+	return tea.NewFrame(s + "\n\nTo quit sooner press ctrl-c, or t to toggle focus reporting...\n")
 }
diff --git a/examples/fullscreen/main.go b/examples/fullscreen/main.go
index 40fa9c1..4dff5fe 100644
--- a/examples/fullscreen/main.go
+++ b/examples/fullscreen/main.go
@@ -45,8 +45,8 @@ func (m model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
 	return m, nil
 }
 
-func (m model) View() string {
-	return fmt.Sprintf("\n\n     Hi. This program will exit in %d seconds...", m)
+func (m model) View() fmt.Stringer {
+	return tea.NewFrame(fmt.Sprintf("\n\n     Hi. This program will exit in %d seconds...", m))
 }
 
 func tick() tea.Cmd {
diff --git a/examples/glamour/main.go b/examples/glamour/main.go
index 2d4939a..1873964 100644
--- a/examples/glamour/main.go
+++ b/examples/glamour/main.go
@@ -121,8 +121,8 @@ func (e example) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	}
 }
 
-func (e example) View() string {
-	return e.viewport.View() + e.helpView()
+func (e example) View() fmt.Stringer {
+	return tea.NewFrame(e.viewport.View() + e.helpView())
 }
 
 func (e example) helpView() string {
diff --git a/examples/help/main.go b/examples/help/main.go
index d6c12ea..4e3a177 100644
--- a/examples/help/main.go
+++ b/examples/help/main.go
@@ -112,9 +112,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, nil
 }
 
-func (m model) View() string {
+func (m model) View() fmt.Stringer {
 	if m.quitting {
-		return "Bye!\n"
+		return tea.NewFrame("Bye!\n")
 	}
 
 	var status string
@@ -127,7 +127,7 @@ func (m model) View() string {
 	helpView := m.help.View(m.keys)
 	height := 8 - strings.Count(status, "\n") - strings.Count(helpView, "\n")
 
-	return status + strings.Repeat("\n", height) + helpView
+	return tea.NewFrame(status + strings.Repeat("\n", height) + helpView)
 }
 
 func main() {
diff --git a/examples/http/main.go b/examples/http/main.go
index f57b2d7..b5a2137 100644
--- a/examples/http/main.go
+++ b/examples/http/main.go
@@ -58,14 +58,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	}
 }
 
-func (m model) View() string {
+func (m model) View() fmt.Stringer {
 	s := fmt.Sprintf("Checking %s...", url)
 	if m.err != nil {
 		s += fmt.Sprintf("something went wrong: %s", m.err)
 	} else if m.status != 0 {
 		s += fmt.Sprintf("%d %s", m.status, http.StatusText(m.status))
 	}
-	return s + "\n"
+	return tea.NewFrame(s + "\n")
 }
 
 func checkServer() tea.Msg {
diff --git a/examples/list-default/main.go b/examples/list-default/main.go
index 1944892..487c0be 100644
--- a/examples/list-default/main.go
+++ b/examples/list-default/main.go
@@ -43,8 +43,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, cmd
 }
 
-func (m model) View() string {
-	return docStyle.Render(m.list.View())
+func (m model) View() fmt.Stringer {
+	return tea.NewFrame(docStyle.Render(m.list.View()))
 }
 
 func main() {
diff --git a/examples/list-fancy/main.go b/examples/list-fancy/main.go
index 96f7fb3..212969d 100644
--- a/examples/list-fancy/main.go
+++ b/examples/list-fancy/main.go
@@ -234,13 +234,13 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, tea.Batch(cmds...)
 }
 
-func (m model) View() string {
+func (m model) View() fmt.Stringer {
 	// Don't render until we have everything we queried for.
 	if !m.queries.Ready() {
-		return ""
+		return tea.NewFrame("")
 	}
 
-	return m.styles.app.Render(m.list.View())
+	return tea.NewFrame(m.styles.app.Render(m.list.View()))
 }
 
 func main() {
diff --git a/examples/list-simple/main.go b/examples/list-simple/main.go
index 4f8067c..e7da9ab 100644
--- a/examples/list-simple/main.go
+++ b/examples/list-simple/main.go
@@ -131,19 +131,19 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, cmd
 }
 
-func (m model) View() string {
+func (m model) View() fmt.Stringer {
 	if !m.ready {
 		// Don't render anything until the query for the background color has
 		// finished.
-		return ""
+		return tea.NewFrame("")
 	}
 	if m.choice != "" {
-		return m.styles.quitText.Render(fmt.Sprintf("%s? Sounds good to me.", m.choice))
+		return tea.NewFrame(m.styles.quitText.Render(fmt.Sprintf("%s? Sounds good to me.", m.choice)))
 	}
 	if m.quitting {
-		return m.styles.quitText.Render("Not hungry? That’s cool.")
+		return tea.NewFrame(m.styles.quitText.Render("Not hungry? That’s cool."))
 	}
-	return "\n" + m.list.View()
+	return tea.NewFrame("\n" + m.list.View())
 }
 
 func main() {
diff --git a/examples/mouse/main.go b/examples/mouse/main.go
index 991b0f2..857fb3b 100644
--- a/examples/mouse/main.go
+++ b/examples/mouse/main.go
@@ -4,6 +4,7 @@ package main
 // coordinates and events.
 
 import (
+	"fmt"
 	"log"
 
 	tea "github.com/charmbracelet/bubbletea/v2"
@@ -37,8 +38,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, nil
 }
 
-func (m model) View() string {
+func (m model) View() fmt.Stringer {
 	s := "Do mouse stuff. When you're done press q to quit.\n"
 
-	return s
+	return tea.NewFrame(s)
 }
diff --git a/examples/package-manager/main.go b/examples/package-manager/main.go
index fbc1836..7f39b1b 100644
--- a/examples/package-manager/main.go
+++ b/examples/package-manager/main.go
@@ -91,12 +91,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, nil
 }
 
-func (m model) View() string {
+func (m model) View() fmt.Stringer {
 	n := len(m.packages)
 	w := lipgloss.Width(fmt.Sprintf("%d", n))
 
 	if m.done {
-		return doneStyle.Render(fmt.Sprintf("Done! Installed %d packages.\n", n))
+		return tea.NewFrame(doneStyle.Render(fmt.Sprintf("Done! Installed %d packages.\n", n)))
 	}
 
 	pkgCount := fmt.Sprintf(" %*d/%*d", w, m.index, w, n)
@@ -111,7 +111,7 @@ func (m model) View() string {
 	cellsRemaining := max(0, m.width-lipgloss.Width(spin+info+prog+pkgCount))
 	gap := strings.Repeat(" ", cellsRemaining)
 
-	return spin + info + gap + prog + pkgCount
+	return tea.NewFrame(spin + info + gap + prog + pkgCount)
 }
 
 type installedPkgMsg string
diff --git a/examples/pager/main.go b/examples/pager/main.go
index 05a7c71..11114f2 100644
--- a/examples/pager/main.go
+++ b/examples/pager/main.go
@@ -76,11 +76,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, tea.Batch(cmds...)
 }
 
-func (m model) View() string {
+func (m model) View() fmt.Stringer {
 	if !m.ready {
-		return "\n  Initializing..."
+		return tea.NewFrame("\n  Initializing...")
 	}
-	return fmt.Sprintf("%s\n%s\n%s", m.headerView(), m.viewport.View(), m.footerView())
+	return tea.NewFrame(fmt.Sprintf("%s\n%s\n%s", m.headerView(), m.viewport.View(), m.footerView()))
 }
 
 func (m model) headerView() string {
diff --git a/examples/paginator/main.go b/examples/paginator/main.go
index 83c234e..e466e56 100644
--- a/examples/paginator/main.go
+++ b/examples/paginator/main.go
@@ -70,9 +70,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, cmd
 }
 
-func (m model) View() string {
+func (m model) View() fmt.Stringer {
 	if !m.ready {
-		return ""
+		return tea.NewFrame("")
 	}
 
 	var b strings.Builder
@@ -83,7 +83,7 @@ func (m model) View() string {
 	}
 	b.WriteString("  " + m.paginator.View())
 	b.WriteString("\n\n  h/l ←/→ page • q: quit\n")
-	return b.String()
+	return &b
 }
 
 func main() {
diff --git a/examples/pipe/main.go b/examples/pipe/main.go
index a6d81fa..6951a6f 100644
--- a/examples/pipe/main.go
+++ b/examples/pipe/main.go
@@ -85,9 +85,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, cmd
 }
 
-func (m model) View() string {
-	return fmt.Sprintf(
+func (m model) View() fmt.Stringer {
+	return tea.NewFrame(fmt.Sprintf(
 		"\nYou piped in: %s\n\nPress ^C to exit",
 		m.userInput.View(),
-	)
+	))
 }
diff --git a/examples/prevent-quit/main.go b/examples/prevent-quit/main.go
index 75c258d..91674ac 100644
--- a/examples/prevent-quit/main.go
+++ b/examples/prevent-quit/main.go
@@ -131,13 +131,13 @@ func (m model) updatePromptView(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, nil
 }
 
-func (m model) View() string {
+func (m model) View() fmt.Stringer {
 	if m.quitting {
 		if m.hasChanges {
 			text := lipgloss.JoinHorizontal(lipgloss.Top, "You have unsaved changes. Quit without saving?", choiceStyle.Render("[yN]"))
-			return quitViewStyle.Render(text)
+			return tea.NewFrame(quitViewStyle.Render(text))
 		}
-		return "Very important. Thank you.\n"
+		return tea.NewFrame("Very important. Thank you.\n")
 	}
 
 	helpView := m.help.ShortHelpView([]key.Binding{
@@ -145,10 +145,10 @@ func (m model) View() string {
 		m.keymap.quit,
 	})
 
-	return fmt.Sprintf(
+	return tea.NewFrame(fmt.Sprintf(
 		"Type some important things.\n%s\n %s\n %s",
 		m.textarea.View(),
 		saveTextStyle.Render(m.saveText),
 		helpView,
-	) + "\n\n"
+	) + "\n\n")
 }
diff --git a/examples/print-key/main.go b/examples/print-key/main.go
index 3ed99f5..ab726be 100644
--- a/examples/print-key/main.go
+++ b/examples/print-key/main.go
@@ -1,6 +1,7 @@
 package main
 
 import (
+	"fmt"
 	"log"
 
 	tea "github.com/charmbracelet/bubbletea/v2"
@@ -39,8 +40,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, nil
 }
 
-func (m model) View() string {
-	return "Press any key to see its details printed to the terminal. Press 'ctrl+c' to quit."
+func (m model) View() fmt.Stringer {
+	return tea.NewFrame("Press any key to see its details printed to the terminal. Press 'ctrl+c' to quit.")
 }
 
 func main() {
diff --git a/examples/progress-animated/main.go b/examples/progress-animated/main.go
index b0b4751..efdc52c 100644
--- a/examples/progress-animated/main.go
+++ b/examples/progress-animated/main.go
@@ -79,11 +79,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	}
 }
 
-func (m model) View() string {
+func (m model) View() fmt.Stringer {
 	pad := strings.Repeat(" ", padding)
-	return "\n" +
+	return tea.NewFrame("\n" +
 		pad + m.progress.View() + "\n\n" +
-		pad + helpStyle("Press any key to quit")
+		pad + helpStyle("Press any key to quit"))
 }
 
 func tickCmd() tea.Cmd {
diff --git a/examples/progress-download/tui.go b/examples/progress-download/tui.go
index dfe6724..6e6139f 100644
--- a/examples/progress-download/tui.go
+++ b/examples/progress-download/tui.go
@@ -1,6 +1,7 @@
 package main
 
 import (
+	"fmt"
 	"strings"
 	"time"
 
@@ -73,13 +74,13 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	}
 }
 
-func (m model) View() string {
+func (m model) View() fmt.Stringer {
 	if m.err != nil {
-		return "Error downloading: " + m.err.Error() + "\n"
+		return tea.NewFrame("Error downloading: " + m.err.Error() + "\n")
 	}
 
 	pad := strings.Repeat(" ", padding)
-	return "\n" +
+	return tea.NewFrame("\n" +
 		pad + m.progress.View() + "\n\n" +
-		pad + helpStyle("Press any key to quit")
+		pad + helpStyle("Press any key to quit"))
 }
diff --git a/examples/progress-static/main.go b/examples/progress-static/main.go
index fdff924..58d78bf 100644
--- a/examples/progress-static/main.go
+++ b/examples/progress-static/main.go
@@ -79,11 +79,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	}
 }
 
-func (m model) View() string {
+func (m model) View() fmt.Stringer {
 	pad := strings.Repeat(" ", padding)
-	return "\n" +
+	return tea.NewFrame("\n" +
 		pad + m.progress.ViewAs(m.percent) + "\n\n" +
-		pad + helpStyle("Press any key to quit")
+		pad + helpStyle("Press any key to quit"))
 }
 
 func tickCmd() tea.Cmd {
diff --git a/examples/query-term/main.go b/examples/query-term/main.go
index 120ccb8..e377f31 100644
--- a/examples/query-term/main.go
+++ b/examples/query-term/main.go
@@ -79,14 +79,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, tea.Batch(cmds...)
 }
 
-func (m model) View() string {
+func (m model) View() fmt.Stringer {
 	var s strings.Builder
 	s.WriteString(m.input.View())
 	if m.err != nil {
 		s.WriteString("\n\nError: " + m.err.Error())
 	}
 	s.WriteString("\n\nPress ctrl+c to quit, enter to write the sequence to terminal")
-	return s.String()
+	return &s
 }
 
 func main() {
diff --git a/examples/realtime/main.go b/examples/realtime/main.go
index 7aa232b..98a6017 100644
--- a/examples/realtime/main.go
+++ b/examples/realtime/main.go
@@ -69,12 +69,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	}
 }
 
-func (m model) View() string {
+func (m model) View() fmt.Stringer {
 	s := fmt.Sprintf("\n %s Events received: %d\n\n Press any key to exit\n", m.spinner.View(), m.responses)
 	if m.quitting {
 		s += "\n"
 	}
-	return s
+	return tea.NewFrame(s)
 }
 
 func main() {
diff --git a/examples/result/main.go b/examples/result/main.go
index d8b21a8..8efc963 100644
--- a/examples/result/main.go
+++ b/examples/result/main.go
@@ -51,7 +51,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, nil
 }
 
-func (m model) View() string {
+func (m model) View() fmt.Stringer {
 	s := strings.Builder{}
 	s.WriteString("What kind of Bubble Tea would you like to order?\n\n")
 
@@ -66,7 +66,7 @@ func (m model) View() string {
 	}
 	s.WriteString("\n(press q to quit)\n")
 
-	return s.String()
+	return &s
 }
 
 func main() {
diff --git a/examples/send-msg/main.go b/examples/send-msg/main.go
index a6b4f69..37af6e6 100644
--- a/examples/send-msg/main.go
+++ b/examples/send-msg/main.go
@@ -73,7 +73,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	}
 }
 
-func (m model) View() string {
+func (m model) View() fmt.Stringer {
 	var s string
 
 	if m.quitting {
@@ -96,7 +96,7 @@ func (m model) View() string {
 		s += "\n"
 	}
 
-	return appStyle.Render(s)
+	return tea.NewFrame(appStyle.Render(s))
 }
 
 func main() {
diff --git a/examples/sequence/main.go b/examples/sequence/main.go
index e0f419d..0dc6392 100644
--- a/examples/sequence/main.go
+++ b/examples/sequence/main.go
@@ -36,8 +36,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, nil
 }
 
-func (m model) View() string {
-	return ""
+func (m model) View() fmt.Stringer {
+	return tea.NewFrame("")
 }
 
 func main() {
diff --git a/examples/set-terminal-color/main.go b/examples/set-terminal-color/main.go
index 52ab178..8293ce3 100644
--- a/examples/set-terminal-color/main.go
+++ b/examples/set-terminal-color/main.go
@@ -1,6 +1,7 @@
 package main
 
 import (
+	"fmt"
 	"log"
 	"strings"
 
@@ -126,9 +127,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, nil
 }
 
-func (m model) View() string {
+func (m model) View() fmt.Stringer {
 	var s strings.Builder
-	var instructions = lipgloss.NewStyle().Width(40).Render("Choose a terminal-wide color to set. All settings will be cleared on exit.")
+	instructions := lipgloss.NewStyle().Width(40).Render("Choose a terminal-wide color to set. All settings will be cleared on exit.")
 
 	switch m.state {
 	case chooseState:
@@ -164,7 +165,7 @@ func (m model) View() string {
 
 	s.WriteString("\n")
 
-	return s.String()
+	return &s
 }
 
 func main() {
diff --git a/examples/set-window-title/main.go b/examples/set-window-title/main.go
index 54a3a20..b8655af 100644
--- a/examples/set-window-title/main.go
+++ b/examples/set-window-title/main.go
@@ -26,10 +26,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, nil
 }
 
-func (m model) View() string {
+func (m model) View() fmt.Stringer {
 	wrap := lipgloss.NewStyle().Width(78).Render
-	return wrap("The window title has been set to '"+windowTitle+"'. It will be cleared on exit.") +
-		"\n\nPress any key to quit."
+	return tea.NewFrame(wrap("The window title has been set to '"+windowTitle+"'. It will be cleared on exit.") +
+		"\n\nPress any key to quit.")
 }
 
 func main() {
diff --git a/examples/simple/main.go b/examples/simple/main.go
index bdbb870..14c9178 100644
--- a/examples/simple/main.go
+++ b/examples/simple/main.go
@@ -64,8 +64,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 // View returns a string based on data in the model. That string which will be
 // rendered to the terminal.
-func (m model) View() string {
-	return fmt.Sprintf("Hi. This program will exit in %d seconds.\n\nTo quit sooner press ctrl-c, or press ctrl-z to suspend...\n", m)
+func (m model) View() fmt.Stringer {
+	return tea.NewFrame(fmt.Sprintf("Hi. This program will exit in %d seconds.\n\nTo quit sooner press ctrl-c, or press ctrl-z to suspend...\n", m))
 }
 
 // Messages are events that we respond to in our Update function. This
diff --git a/examples/space/main.go b/examples/space/main.go
index 1bd18ca..b5a1f3d 100644
--- a/examples/space/main.go
+++ b/examples/space/main.go
@@ -96,7 +96,7 @@ func clamp(value, min, max float64) float64 {
 	return value
 }
 
-func (m model) View() string {
+func (m model) View() fmt.Stringer {
 	// Title
 	title := lipgloss.NewStyle().Bold(true).Render("Space")
 
@@ -113,7 +113,7 @@ func (m model) View() string {
 		s.WriteByte('\n')
 	}
 
-	return lipgloss.JoinVertical(lipgloss.Left, title, s.String())
+	return tea.NewFrame(lipgloss.JoinVertical(lipgloss.Left, title, s.String()))
 }
 
 func main() {
diff --git a/examples/spinner/main.go b/examples/spinner/main.go
index 183410d..5ee5ddb 100644
--- a/examples/spinner/main.go
+++ b/examples/spinner/main.go
@@ -53,15 +53,15 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	}
 }
 
-func (m model) View() string {
+func (m model) View() fmt.Stringer {
 	if m.err != nil {
-		return m.err.Error()
+		return tea.NewFrame(m.err.Error())
 	}
 	str := fmt.Sprintf("\n\n   %s Loading forever...press q to quit\n\n", m.spinner.View())
 	if m.quitting {
-		return str + "\n"
+		return tea.NewFrame(str + "\n")
 	}
-	return str
+	return tea.NewFrame(str)
 }
 
 func main() {
diff --git a/examples/spinners/main.go b/examples/spinners/main.go
index 1ebbed3..83e7dbd 100644
--- a/examples/spinners/main.go
+++ b/examples/spinners/main.go
@@ -85,7 +85,7 @@ func (m *model) resetSpinner() {
 	m.spinner.Spinner = spinners[m.index]
 }
 
-func (m model) View() (s string) {
+func (m model) View() fmt.Stringer {
 	var gap string
 	switch m.index {
 	case 1:
@@ -94,7 +94,8 @@ func (m model) View() (s string) {
 		gap = " "
 	}
 
+	var s string
 	s += fmt.Sprintf("\n %s%s%s\n\n", m.spinner.View(), gap, textStyle("Spinning..."))
 	s += helpStyle("h/l, ←/→: change spinner • q: exit\n")
-	return
+	return tea.NewFrame(s)
 }
diff --git a/examples/splash/main.go b/examples/splash/main.go
index ead6dfd..2a5f586 100644
--- a/examples/splash/main.go
+++ b/examples/splash/main.go
@@ -55,12 +55,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, nil
 }
 
-func (m model) View() string {
+func (m model) View() fmt.Stringer {
 	if m.width == 0 {
-		return "Initializing..."
+		return tea.NewFrame("Initializing...")
 	}
 
-	return m.gradient()
+	return tea.NewFrame(m.gradient())
 }
 
 func (m model) gradient() string {
diff --git a/examples/split-editors/main.go b/examples/split-editors/main.go
index bb9ed36..4779990 100644
--- a/examples/split-editors/main.go
+++ b/examples/split-editors/main.go
@@ -180,7 +180,7 @@ func (m *model) updateKeybindings() {
 	m.keymap.remove.SetEnabled(len(m.inputs) > minInputs)
 }
 
-func (m model) View() string {
+func (m model) View() fmt.Stringer {
 	help := m.help.ShortHelpView([]key.Binding{
 		m.keymap.next,
 		m.keymap.prev,
@@ -194,7 +194,7 @@ func (m model) View() string {
 		views = append(views, m.inputs[i].View())
 	}
 
-	return lipgloss.JoinHorizontal(lipgloss.Top, views...) + "\n\n" + help
+	return tea.NewFrame(lipgloss.JoinHorizontal(lipgloss.Top, views...) + "\n\n" + help)
 }
 
 func main() {
diff --git a/examples/stopwatch/main.go b/examples/stopwatch/main.go
index f58b85e..afa63ff 100644
--- a/examples/stopwatch/main.go
+++ b/examples/stopwatch/main.go
@@ -31,7 +31,7 @@ func (m model) Init() (tea.Model, tea.Cmd) {
 	return m, cmd
 }
 
-func (m model) View() string {
+func (m model) View() fmt.Stringer {
 	// Note: you could further customize the time output by getting the
 	// duration from m.stopwatch.Elapsed(), which returns a time.Duration, and
 	// skip m.stopwatch.View() altogether.
@@ -40,7 +40,7 @@ func (m model) View() string {
 		s = "Elapsed: " + s
 		s += m.helpView()
 	}
-	return s
+	return tea.NewFrame(s)
 }
 
 func (m model) helpView() string {
diff --git a/examples/suspend/main.go b/examples/suspend/main.go
index d20b22b..7b61a38 100644
--- a/examples/suspend/main.go
+++ b/examples/suspend/main.go
@@ -38,12 +38,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, nil
 }
 
-func (m model) View() string {
+func (m model) View() fmt.Stringer {
 	if m.suspending || m.quitting {
-		return ""
+		return tea.NewFrame("")
 	}
 
-	return "\nPress ctrl-z to suspend, ctrl+c to interrupt, q, or esc to exit\n"
+	return tea.NewFrame("\nPress ctrl-z to suspend, ctrl+c to interrupt, q, or esc to exit\n")
 }
 
 func main() {
diff --git a/examples/table-resize/main.go b/examples/table-resize/main.go
index d377667..bca0251 100644
--- a/examples/table-resize/main.go
+++ b/examples/table-resize/main.go
@@ -46,8 +46,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, cmd
 }
 
-func (m model) View() string {
-	return "\n" + m.table.String() + "\n"
+func (m model) View() fmt.Stringer {
+	return tea.NewFrame("\n" + m.table.String() + "\n")
 }
 
 func main() {
diff --git a/examples/table/main.go b/examples/table/main.go
index 0a2c31b..4e3c43b 100644
--- a/examples/table/main.go
+++ b/examples/table/main.go
@@ -42,8 +42,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, cmd
 }
 
-func (m model) View() string {
-	return baseStyle.Render(m.table.View()) + "\n  " + m.table.HelpView() + "\n"
+func (m model) View() fmt.Stringer {
+	return tea.NewFrame(baseStyle.Render(m.table.View()) + "\n  " + m.table.HelpView() + "\n")
 }
 
 func main() {
diff --git a/examples/tabs/main.go b/examples/tabs/main.go
index f159cf8..9cbf8e1 100644
--- a/examples/tabs/main.go
+++ b/examples/tabs/main.go
@@ -82,10 +82,10 @@ func tabBorderWithBottom(left, middle, right string) lipgloss.Border {
 	return border
 }
 
-func (m model) View() string {
+func (m model) View() fmt.Stringer {
 	if m.styles == nil {
 		// Don't render until we've initialized our styles.
-		return ""
+		return tea.NewFrame("")
 	}
 
 	doc := strings.Builder{}
@@ -119,7 +119,7 @@ func (m model) View() string {
 	doc.WriteString(row)
 	doc.WriteString("\n")
 	doc.WriteString(s.window.Width((lipgloss.Width(row) - s.window.GetHorizontalFrameSize())).Render(m.TabContent[m.activeTab]))
-	return s.doc.Render(doc.String())
+	return tea.NewFrame(s.doc.Render(doc.String()))
 }
 
 func main() {
diff --git a/examples/textarea/main.go b/examples/textarea/main.go
index 660bf0e..d47280d 100644
--- a/examples/textarea/main.go
+++ b/examples/textarea/main.go
@@ -7,6 +7,7 @@ import (
 	"fmt"
 	"log"
 
+	"github.com/charmbracelet/bubbles/v2/cursor"
 	"github.com/charmbracelet/bubbles/v2/textarea"
 	tea "github.com/charmbracelet/bubbletea/v2"
 )
@@ -30,6 +31,7 @@ func initialModel() model {
 	ti := textarea.New()
 	ti.Placeholder = "Once upon a time..."
 	ti.Focus()
+	ti.Cursor.SetMode(cursor.CursorHide)
 
 	return model{
 		textarea: ti,
@@ -38,7 +40,9 @@ func initialModel() model {
 }
 
 func (m model) Init() (tea.Model, tea.Cmd) {
-	return m, textarea.Blink
+	return m, tea.Batch(
+		textarea.Blink,
+	)
 }
 
 func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -72,10 +76,19 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, tea.Batch(cmds...)
 }
 
-func (m model) View() string {
-	return fmt.Sprintf(
+func (m model) View() fmt.Stringer {
+	const (
+		xOffset = 6
+		yOffset = 2
+	)
+	f := tea.NewFrame(fmt.Sprintf(
 		"Tell me a story.\n\n%s\n\n%s",
 		m.textarea.View(),
 		"(ctrl+c to quit)",
-	) + "\n\n"
+	) + "\n\n")
+
+	x, y := m.textarea.CursorPosition()
+	f.Cursor = tea.NewCursor(x+xOffset, y+yOffset)
+
+	return f
 }
diff --git a/examples/textinput/main.go b/examples/textinput/main.go
index d024e81..fb004a5 100644
--- a/examples/textinput/main.go
+++ b/examples/textinput/main.go
@@ -64,10 +64,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, cmd
 }
 
-func (m model) View() string {
-	return fmt.Sprintf(
+func (m model) View() fmt.Stringer {
+	return tea.NewFrame(fmt.Sprintf(
 		"What’s your favorite Pokémon?\n\n%s\n\n%s",
 		m.textInput.View(),
 		"(esc to quit)",
-	) + "\n"
+	) + "\n")
 }
diff --git a/examples/textinputs/main.go b/examples/textinputs/main.go
index 916308a..6f1c33d 100644
--- a/examples/textinputs/main.go
+++ b/examples/textinputs/main.go
@@ -147,7 +147,7 @@ func (m *model) updateInputs(msg tea.Msg) tea.Cmd {
 	return tea.Batch(cmds...)
 }
 
-func (m model) View() string {
+func (m model) View() fmt.Stringer {
 	var b strings.Builder
 
 	for i := range m.inputs {
@@ -167,7 +167,7 @@ func (m model) View() string {
 	b.WriteString(cursorModeHelpStyle.Render(m.cursorMode.String()))
 	b.WriteString(helpStyle.Render(" (ctrl+r to change style)"))
 
-	return b.String()
+	return &b
 }
 
 func main() {
diff --git a/examples/timer/main.go b/examples/timer/main.go
index cb1e208..9a11471 100644
--- a/examples/timer/main.go
+++ b/examples/timer/main.go
@@ -75,7 +75,7 @@ func (m model) helpView() string {
 	})
 }
 
-func (m model) View() string {
+func (m model) View() fmt.Stringer {
 	// For a more detailed timer view you could read m.timer.Timeout to get
 	// the remaining time as a time.Duration and skip calling m.timer.View()
 	// entirely.
@@ -89,7 +89,7 @@ func (m model) View() string {
 		s = "Exiting in " + s
 		s += m.helpView()
 	}
-	return s
+	return tea.NewFrame(s)
 }
 
 func main() {
diff --git a/examples/tui-daemon-combo/main.go b/examples/tui-daemon-combo/main.go
index 6892b4e..eba1668 100644
--- a/examples/tui-daemon-combo/main.go
+++ b/examples/tui-daemon-combo/main.go
@@ -102,7 +102,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	}
 }
 
-func (m model) View() string {
+func (m model) View() fmt.Stringer {
 	s := "\n" +
 		m.spinner.View() + " Doing some work...\n\n"
 
@@ -120,7 +120,7 @@ func (m model) View() string {
 		s += "\n"
 	}
 
-	return mainStyle.Render(s)
+	return tea.NewFrame(mainStyle.Render(s))
 }
 
 // processFinishedMsg is sent when a pretend process completes.
diff --git a/examples/views/main.go b/examples/views/main.go
index 1580184..abc02a2 100644
--- a/examples/views/main.go
+++ b/examples/views/main.go
@@ -99,17 +99,17 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 }
 
 // The main view, which just calls the appropriate sub-view
-func (m model) View() string {
+func (m model) View() fmt.Stringer {
 	var s string
 	if m.Quitting {
-		return "\n  See you later!\n\n"
+		return tea.NewFrame("\n  See you later!\n\n")
 	}
 	if !m.Chosen {
 		s = choicesView(m)
 	} else {
 		s = chosenView(m)
 	}
-	return mainStyle.Render("\n" + s + "\n\n")
+	return tea.NewFrame(mainStyle.Render("\n" + s + "\n\n"))
 }
 
 // Sub-update functions
diff --git a/examples/window-size/main.go b/examples/window-size/main.go
index 44ebb9b..c33ea55 100644
--- a/examples/window-size/main.go
+++ b/examples/window-size/main.go
@@ -3,6 +3,7 @@ package main
 // A simple program that queries and displays the window-size.
 
 import (
+	"fmt"
 	"log"
 
 	tea "github.com/charmbracelet/bubbletea/v2"
@@ -37,8 +38,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, nil
 }
 
-func (m model) View() string {
-	s := "\nWhen you're done press q to quit.\nPress any other key to query the window-size.\n"
+func (m model) View() fmt.Stringer {
+	s := tea.NewFrame("\nWhen you're done press q to quit.\nPress any other key to query the window-size.\n")
 
 	return s
 }
diff --git a/frame.go b/frame.go
new file mode 100644
index 0000000..5338c7f
--- /dev/null
+++ b/frame.go
@@ -0,0 +1,51 @@
+package tea
+
+import "image/color"
+
+// Cursor represents a cursor on the terminal screen.
+type Cursor struct {
+	// Position is a [Position] that determines the cursor's position on the
+	// screen relative to the top left corner of the frame.
+	Position Position
+
+	// Color is a [color.Color] that determines the cursor's color.
+	Color color.Color
+
+	// Shape is a [CursorShape] that determines the cursor's shape.
+	Shape CursorShape
+
+	// Blink is a boolean that determines whether the cursor should blink.
+	Blink bool
+}
+
+// NewCursor returns a new cursor with the default settings and the given
+// position.
+func NewCursor(x, y int) *Cursor {
+	return &Cursor{
+		Position: Position{X: x, Y: y},
+		Color:    nil,
+		Shape:    CursorBlock,
+		Blink:    true,
+	}
+}
+
+// Frame represents a single frame of the program's output.
+type Frame struct {
+	// Content contains the frame's content. This is the only required field.
+	// It should be a string of text and ANSI escape codes.
+	Content string
+
+	// Cursor contains cursor settings for the frame. If nil, the cursor will
+	// be hidden.
+	Cursor *Cursor
+}
+
+// NewFrame creates a new frame with the given content.
+func NewFrame(content string) Frame {
+	return Frame{Content: content}
+}
+
+// String implements the fmt.Stringer interface for [Frame].
+func (f Frame) String() string {
+	return f.Content
+}
diff --git a/nil_renderer.go b/nil_renderer.go
index ed7341b..cea259b 100644
--- a/nil_renderer.go
+++ b/nil_renderer.go
@@ -1,6 +1,10 @@
 package tea
 
-import "github.com/charmbracelet/colorprofile"
+import (
+	"fmt"
+
+	"github.com/charmbracelet/colorprofile"
+)
 
 // nilRenderer is a no-op renderer. It implements the Renderer interface but
 // doesn't render anything to the terminal.
@@ -26,9 +30,6 @@ func (n nilRenderer) hideCursor() {}
 // insertAbove implements renderer.
 func (n nilRenderer) insertAbove(string) {}
 
-// moveTo implements renderer.
-func (n nilRenderer) moveTo(int, int) {}
-
 // resize implements renderer.
 func (n nilRenderer) resize(int, int) {}
 
@@ -45,7 +46,7 @@ func (nilRenderer) flush() error { return nil }
 func (nilRenderer) close() error { return nil }
 
 // render implements the Renderer interface.
-func (nilRenderer) render(string) {}
+func (nilRenderer) render(fmt.Stringer) {}
 
 // reset implements the Renderer interface.
 func (nilRenderer) reset() {}
diff --git a/renderer.go b/renderer.go
index ae8738e..333f838 100644
--- a/renderer.go
+++ b/renderer.go
@@ -19,7 +19,7 @@ type renderer interface {
 	close() error
 
 	// render renders a frame to the output.
-	render(string)
+	render(fmt.Stringer)
 
 	// flush flushes the renderer's buffer to the output.
 	flush() error
@@ -48,9 +48,6 @@ type renderer interface {
 	// setColorProfile sets the color profile.
 	setColorProfile(colorprofile.Profile)
 
-	// moveTo moves the cursor to the given position.
-	moveTo(int, int)
-
 	// clearScreen clears the screen.
 	clearScreen()
 
@@ -95,3 +92,15 @@ func Printf(template string, args ...interface{}) Cmd {
 		}
 	}
 }
+
+// encodeCursorStyle returns the integer value for the given cursor style and
+// blink state.
+func encodeCursorStyle(style CursorShape, blink bool) int {
+	// We're using the ANSI escape sequence values for cursor styles.
+	// We need to map both [style] and [steady] to the correct value.
+	style = (style * 2) + 1
+	if !blink {
+		style++
+	}
+	return int(style)
+}
diff --git a/tea.go b/tea.go
index 893c79b..565f28a 100644
--- a/tea.go
+++ b/tea.go
@@ -53,9 +53,11 @@ type Model interface {
 	// and, in response, update the model and/or send a command.
 	Update(Msg) (Model, Cmd)
 
-	// View renders the program's UI, which is just a string. The view is
-	// rendered after every Update.
-	View() string
+	// View renders the program's UI, which is just a [fmt.Stringer]. The view
+	// is rendered after every Update.
+	// The main model can return a [Frame] to set the cursor position and
+	// style.
+	View() fmt.Stringer
 }
 
 // Cmd is an IO operation that returns a message when it's complete. If it's
@@ -474,9 +476,6 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) {
 					}
 				}
 
-			case setCursorStyle:
-				p.execute(ansi.SetCursorStyle(int(msg)))
-
 			case modeReportMsg:
 				switch msg.Mode {
 				case ansi.GraphemeClusteringMode:
@@ -537,20 +536,26 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) {
 			case setBackgroundColorMsg:
 				if msg.Color != nil {
 					p.execute(ansi.SetBackgroundColor(msg.Color))
-					p.setBg = msg.Color
+				} else {
+					p.execute(ansi.ResetBackgroundColor)
 				}
+				p.setBg = msg.Color
 
 			case setForegroundColorMsg:
 				if msg.Color != nil {
 					p.execute(ansi.SetForegroundColor(msg.Color))
-					p.setFg = msg.Color
+				} else {
+					p.execute(ansi.ResetForegroundColor)
 				}
+				p.setFg = msg.Color
 
 			case setCursorColorMsg:
 				if msg.Color != nil {
 					p.execute(ansi.SetCursorColor(msg.Color))
-					p.setCc = msg.Color
+				} else {
+					p.execute(ansi.ResetCursorColor)
 				}
+				p.setCc = msg.Color
 
 			case backgroundColorMsg:
 				p.execute(ansi.RequestBackgroundColor)
@@ -674,9 +679,6 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) {
 			case RawMsg:
 				p.execute(fmt.Sprint(msg.Msg))
 
-			case setCursorPosMsg:
-				p.renderer.moveTo(msg.X, msg.Y)
-
 			case printLineMessage:
 				p.renderer.insertAbove(msg.messageBody)
 
@@ -691,9 +693,19 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) {
 			}
 
 			var cmd Cmd
-			model, cmd = model.Update(msg)  // run update
-			cmds <- cmd                     // process command (if any)
-			p.renderer.render(model.View()) //nolint:errcheck // send view to renderer
+			model, cmd = model.Update(msg) // run update
+			cmds <- cmd                    // process command (if any)
+
+			view := model.View()
+			switch view := view.(type) {
+			case Frame:
+				// Ensure we reset the cursor color on exit.
+				if view.Cursor != nil {
+					p.setCc = view.Cursor.Color
+				}
+			}
+
+			p.renderer.render(view) //nolint:errcheck // send view to renderer
 		}
 	}
 }


Test patch:
diff --git a/exec_test.go b/exec_test.go
index 6bc28a5..ac5b6c2 100644
--- a/exec_test.go
+++ b/exec_test.go
@@ -2,6 +2,7 @@ package tea
 
 import (
 	"bytes"
+	"fmt"
 	"os/exec"
 	"runtime"
 	"testing"
@@ -33,8 +34,8 @@ func (m *testExecModel) Update(msg Msg) (Model, Cmd) {
 	return m, nil
 }
 
-func (m *testExecModel) View() string {
-	return "\n"
+func (m *testExecModel) View() fmt.Stringer {
+	return NewFrame("\n")
 }
 
 func TestTeaExec(t *testing.T) {
diff --git a/tea_test.go b/tea_test.go
index 8bc16ce..3477c80 100644
--- a/tea_test.go
+++ b/tea_test.go
@@ -4,6 +4,7 @@ import (
 	"bytes"
 	"context"
 	"errors"
+	"fmt"
 	"sync/atomic"
 	"testing"
 	"time"
@@ -37,9 +38,9 @@ func (m *testModel) Update(msg Msg) (Model, Cmd) {
 	return m, nil
 }
 
-func (m *testModel) View() string {
+func (m *testModel) View() fmt.Stringer {
 	m.executed.Store(true)
-	return "success\n"
+	return NewFrame("success\n")
 }
 
 func TestTeaModel(t *testing.T) {