diff --git a/CHANGELOG.md b/CHANGELOG.md
index 608db70d773ee717187a3e5c3395b942ef251279..f0f93e3c0f348c7fbb358651ef2875a5b04cdc7d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
 # Changelog 
 
+## Version 0.18.0 (2024)
+
+- Add text placeholder plugin
+
 ## Version 0.17.0 (2024)
 
 - Update to cosmic-text 0.11.2
diff --git a/Cargo.lock b/Cargo.lock
index 7622718f8802d485f2ec8b119f81db720bcc886c..bcd40f26fee0c7d2734e944265a96ac69e4a38fa 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -406,7 +406,7 @@ dependencies = [
 
 [[package]]
 name = "bevy_cosmic_edit"
-version = "0.17.0"
+version = "0.18.0"
 dependencies = [
  "arboard",
  "bevy",
diff --git a/Cargo.toml b/Cargo.toml
index c47d624d13b835c3da7c01022953ba56b2733acf..7f44d8b6e4cf357ceb5e42cad7ca577074da1a06 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,7 +1,7 @@
 workspace = { members = ["util"] }
 [package]
 name = "bevy_cosmic_edit"
-version = "0.17.0"
+version = "0.18.0"
 edition = "2021"
 license = "MIT OR Apache-2.0"
 description = "Bevy cosmic-text multiline text input"
@@ -13,6 +13,7 @@ exclude = ["assets/*"]
 
 [features]
 multicam = []
+placeholder = []
 
 # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
 
@@ -43,8 +44,12 @@ arboard = "3.2.0"
 js-sys = "0.3.67"
 wasm-bindgen = "0.2.92"
 wasm-bindgen-futures = "0.4.42"
-web-sys = { version = "0.3.67", features = ["Clipboard", "Navigator", "Window"] }
+web-sys = { version = "0.3.67", features = [
+  "Clipboard",
+  "Navigator",
+  "Window",
+] }
 
 [dev-dependencies]
 insta = "1.29.0"
-util = {path="./util/"}
+util = { path = "./util/" }
diff --git a/examples/placeholder.rs b/examples/placeholder.rs
new file mode 100644
index 0000000000000000000000000000000000000000..bc081eac7c505b91ecf1b0d5160ab98d14ef3124
--- /dev/null
+++ b/examples/placeholder.rs
@@ -0,0 +1,80 @@
+use bevy::prelude::*;
+use bevy_cosmic_edit::{placeholder::Placeholder, *};
+use util::{
+    bevy_color_to_cosmic, change_active_editor_ui, deselect_editor_on_esc, print_editor_text,
+};
+
+fn setup(mut commands: Commands, mut font_system: ResMut<CosmicFontSystem>) {
+    let camera_bundle = Camera2dBundle {
+        camera: Camera {
+            clear_color: ClearColorConfig::Custom(Color::PINK),
+            ..default()
+        },
+        ..default()
+    };
+    commands.spawn(camera_bundle);
+
+    let mut attrs = Attrs::new();
+    attrs = attrs.family(Family::Name("Victor Mono"));
+    attrs = attrs.color(CosmicColor::rgb(0x94, 0x00, 0xD3));
+
+    let cosmic_edit =
+        commands
+            .spawn((
+                CosmicEditBundle {
+                    buffer: CosmicBuffer::new(&mut font_system, Metrics::new(20., 20.))
+                        .with_rich_text(&mut font_system, vec![("", attrs)], attrs),
+                    text_position: CosmicTextPosition::Center,
+                    ..default()
+                },
+                Placeholder::new(
+                    "Placeholder",
+                    attrs.color(bevy_color_to_cosmic(Color::GRAY)),
+                ),
+            ))
+            .id();
+
+    commands
+        .spawn(
+            // Use buttonbundle for layout
+            // Includes Interaction and UiImage which are used by the plugin.
+            ButtonBundle {
+                style: Style {
+                    width: Val::Percent(100.),
+                    height: Val::Percent(100.),
+                    ..default()
+                },
+                ..default()
+            },
+        )
+        // point editor at this entity.
+        // Plugin looks for UiImage and sets it's
+        // texture to the editor's rendered image
+        .insert(CosmicSource(cosmic_edit));
+}
+
+fn main() {
+    let font_bytes: &[u8] = include_bytes!("../assets/fonts/VictorMono-Regular.ttf");
+    let font_config = CosmicFontConfig {
+        fonts_dir_path: None,
+        font_bytes: Some(vec![font_bytes]),
+        load_system_fonts: true,
+    };
+
+    App::new()
+        .add_plugins(DefaultPlugins)
+        .add_plugins(CosmicEditPlugin {
+            font_config,
+            ..default()
+        })
+        .add_systems(Startup, setup)
+        .add_systems(
+            Update,
+            (
+                print_editor_text,
+                change_active_editor_ui,
+                deselect_editor_on_esc,
+            ),
+        )
+        .run();
+}
diff --git a/src/buffer.rs b/src/buffer.rs
index 8f58564057c5fbf66dda42fba81c57c10aedbc02..1d32295ba20b873ad0403f7aa34f931e0f1056f6 100644
--- a/src/buffer.rs
+++ b/src/buffer.rs
@@ -15,8 +15,6 @@ impl<'s, 'r> CosmicBuffer {
         Self(Buffer::new(font_system, metrics))
     }
 
-    // TODO: Set redraw when setting text
-
     // Das a lotta boilerplate just to hide the shaping argument
     pub fn with_text(
         mut self,
diff --git a/src/input.rs b/src/input.rs
index 5052197350ad62e46c0a5af8e40515745a518bdd..087cef09eeb029f61878ad86930eb2c2a5511f41 100644
--- a/src/input.rs
+++ b/src/input.rs
@@ -310,7 +310,6 @@ pub(crate) fn input_kb(
         }
         if should_jump && keys.just_pressed(KeyCode::Home) {
             editor.action(&mut font_system.0, Action::Motion(Motion::BufferStart));
-            editor.set_redraw(true);
             if !shift {
                 editor.set_selection(Selection::None);
             }
@@ -318,7 +317,6 @@ pub(crate) fn input_kb(
         }
         if should_jump && keys.just_pressed(KeyCode::End) {
             editor.action(&mut font_system.0, Action::Motion(Motion::BufferEnd));
-            editor.set_redraw(true);
             if !shift {
                 editor.set_selection(Selection::None);
             }
@@ -534,7 +532,6 @@ pub(crate) fn input_kb(
                     for c in b {
                         let c: char = (*c).into();
                         editor.action(&mut font_system.0, Action::Insert(c));
-                        editor.set_redraw(true);
                     }
                 }
             }
diff --git a/src/lib.rs b/src/lib.rs
index 0cc9cf3c8ddce3fbb9ce532758cc5ae45fecb383..be981847c2a7b0dcef04a622b9901e489eff6597 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -7,9 +7,13 @@ mod input;
 mod layout;
 mod render;
 
+mod plugins;
+pub use plugins::*;
+
 use std::{path::PathBuf, time::Duration};
 
 use bevy::{prelude::*, transform::TransformSystem};
+
 use buffer::{
     add_font_system, set_editor_redraw, set_initial_scale, set_redraw, swap_target_handle,
 };
@@ -192,6 +196,9 @@ impl Default for CosmicFontConfig {
     }
 }
 
+#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
+pub struct KbInput;
+
 /// Plugin struct that adds systems and initializes resources related to cosmic edit functionality.
 #[derive(Default)]
 pub struct CosmicEditPlugin {
@@ -224,7 +231,10 @@ impl Plugin for CosmicEditPlugin {
                 .chain(),
         )
         .add_systems(PreUpdate, (input_mouse,).chain())
-        .add_systems(Update, (input_kb, reshape, blink_cursor).chain())
+        .add_systems(
+            Update,
+            (input_kb, reshape, blink_cursor).chain().in_set(KbInput),
+        )
         .add_systems(
             PostUpdate,
             (
@@ -264,9 +274,18 @@ impl Plugin for CosmicEditPlugin {
             app.insert_resource(WasmPasteAsyncChannel { tx, rx })
                 .add_systems(Update, poll_wasm_paste);
         }
+
+        add_feature_plugins(app);
     }
 }
 
+fn add_feature_plugins(app: &mut App) -> &mut App {
+    #[cfg(feature = "placeholder")]
+    app.add_plugins(plugins::placeholder::PlaceholderPlugin);
+
+    app
+}
+
 fn create_cosmic_font_system(cosmic_font_config: CosmicFontConfig) -> FontSystem {
     let locale = sys_locale::get_locale().unwrap_or_else(|| String::from("en-US"));
     let mut db = cosmic_text::fontdb::Database::new();
diff --git a/src/plugins/mod.rs b/src/plugins/mod.rs
new file mode 100644
index 0000000000000000000000000000000000000000..2c07c75b01ca1a0c47da5a42c846fcb4bd87a1d0
--- /dev/null
+++ b/src/plugins/mod.rs
@@ -0,0 +1,2 @@
+#[cfg(feature = "placeholder")]
+pub mod placeholder;
diff --git a/src/plugins/placeholder/mod.rs b/src/plugins/placeholder/mod.rs
new file mode 100644
index 0000000000000000000000000000000000000000..65d7b704b31b092676722bda9eb64a5c0c4d6678
--- /dev/null
+++ b/src/plugins/placeholder/mod.rs
@@ -0,0 +1,199 @@
+use bevy::prelude::*;
+use cosmic_text::{Attrs, Edit};
+
+use crate::{
+    CosmicBuffer, CosmicEditor, CosmicFontSystem, CosmicTextChanged, DefaultAttrs, KbInput,
+};
+
+#[derive(Component)]
+pub struct Placeholder {
+    pub text: &'static str,
+    pub attrs: Attrs<'static>,
+    active: bool,
+}
+
+impl Placeholder {
+    pub fn new(text: impl Into<&'static str>, attrs: Attrs<'static>) -> Self {
+        Self {
+            active: false,
+            text: text.into(),
+            attrs,
+        }
+    }
+}
+
+pub struct PlaceholderPlugin;
+
+impl Plugin for PlaceholderPlugin {
+    fn build(&self, app: &mut App) {
+        app.add_systems(
+            Update,
+            (
+                add_placeholder_to_buffer,
+                add_placeholder_to_editor,
+                move_cursor_to_start_of_placeholder,
+                remove_placeholder_on_input,
+            )
+                .chain()
+                .after(KbInput),
+        );
+    }
+}
+
+fn add_placeholder_to_buffer(
+    mut q: Query<(&mut CosmicBuffer, &mut Placeholder)>,
+    mut font_system: ResMut<CosmicFontSystem>,
+) {
+    for (mut buffer, mut placeholder) in q.iter_mut() {
+        if placeholder.active {
+            return;
+        }
+
+        if buffer.get_text().is_empty() {
+            buffer.set_text(&mut font_system, placeholder.text, placeholder.attrs);
+            placeholder.active = true;
+        }
+    }
+}
+
+fn add_placeholder_to_editor(
+    mut q: Query<(&mut CosmicEditor, &mut Placeholder)>,
+    mut font_system: ResMut<CosmicFontSystem>,
+) {
+    for (mut editor, mut placeholder) in q.iter_mut() {
+        if placeholder.active {
+            // PERF: Removing this guard fixes single char placeholder deletion
+            // BUT makes the check and buffer update run every frame
+            // return;
+        }
+
+        editor.with_buffer_mut(|buffer| {
+            if buffer.lines.len() > 1 {
+                return;
+            }
+
+            if buffer.lines[0].clone().into_text().is_empty() {
+                buffer.set_text(
+                    &mut font_system,
+                    placeholder.text,
+                    placeholder.attrs,
+                    cosmic_text::Shaping::Advanced,
+                );
+                placeholder.active = true;
+                buffer.set_redraw(true);
+            }
+        })
+    }
+}
+
+fn move_cursor_to_start_of_placeholder(mut q: Query<(&mut CosmicEditor, &mut Placeholder)>) {
+    for (mut editor, placeholder) in q.iter_mut() {
+        if placeholder.active {
+            editor.set_cursor(cosmic_text::Cursor::new(0, 0));
+        }
+    }
+}
+
+fn remove_placeholder_on_input(
+    mut q: Query<(&mut CosmicEditor, &mut Placeholder, &DefaultAttrs)>,
+    evr: EventReader<CosmicTextChanged>,
+    mut font_system: ResMut<CosmicFontSystem>,
+) {
+    for (mut editor, mut placeholder, attrs) in q.iter_mut() {
+        if !placeholder.active {
+            return;
+        }
+        if evr.is_empty() {
+            return;
+        }
+
+        let mut lines = 0;
+
+        let last_line = editor.with_buffer_mut(|b| {
+            lines = b.lines.len();
+
+            if lines > 1 {
+                let mut full_text: String = b
+                    .lines
+                    .iter()
+                    .map(|l| {
+                        let mut s = l.clone().into_text().replace(placeholder.text, "");
+                        // Extra newline on enter to prevent reading as an empty buffer
+                        s.push('\n');
+                        s
+                    })
+                    .collect();
+
+                if lines > 2 {
+                    // for pasted text, remove trailing newline
+                    full_text = full_text
+                        .strip_suffix('\n')
+                        .expect("oop something broke in multiline placeholder removal")
+                        .to_string();
+                }
+
+                b.set_text(
+                    &mut font_system,
+                    full_text.as_str(),
+                    attrs.0.as_attrs(),
+                    cosmic_text::Shaping::Advanced,
+                );
+
+                let last_line = full_text.lines().last();
+
+                return last_line.map(|line| line.to_string());
+            }
+
+            let single_line = b.lines[0].clone().into_text().replace(placeholder.text, "");
+
+            if single_line.is_empty() {
+                return None;
+            }
+
+            {
+                // begin hacky fix for delete key in empty placeholder widget
+
+                let p = placeholder
+                    .text
+                    .chars()
+                    .next()
+                    .expect("Couldn't get first char of placeholder.");
+
+                let laceholder = placeholder
+                    .text
+                    .strip_prefix(p)
+                    .expect("Couldn't remove first char of placeholder.");
+
+                if single_line.as_str() == laceholder {
+                    b.set_text(
+                        &mut font_system,
+                        placeholder.text,
+                        placeholder.attrs,
+                        cosmic_text::Shaping::Advanced,
+                    );
+                    return None;
+                }
+            } // end hacky fix
+
+            b.set_text(
+                &mut font_system,
+                single_line.as_str(),
+                attrs.0.as_attrs(),
+                cosmic_text::Shaping::Advanced,
+            );
+
+            Some(single_line)
+        });
+
+        let Some(last_line) = last_line else {
+            return;
+        };
+
+        editor.set_cursor(cosmic_text::Cursor::new(
+            (lines - 1).max(0),
+            last_line.bytes().len(),
+        ));
+
+        placeholder.active = false;
+    }
+}