I wrote an R Shiny app which allows you to interactively choose the location of labels placed using ggrepel::geom_label_repel() on a ggplot object.
The aim of the app is to streamline the process of deliberately placing labels when the automated label placement algorithm doesn’t provide an acceptable solution.
Run the app by placing the code below in a file called app.R, then run from R using shiny::runApp("./app.R").

# Shiny app to interactively place ggrepel points
# John L. Godlee (johngodlee@gmail.com)
# Last updated: 2025-12-07
# Packages
library(shiny)
library(ggplot2)
library(ggrepel)
# UI
ui <- fluidPage(
titlePanel("Interactive ggrepel Label Positioner"),
fluidRow(
column(3,
wellPanel(
h4("Upload Data"),
fileInput("csv_file", "Choose CSV File",
accept = c("text/csv",
"text/comma-separated-values,text/plain",
".csv")),
p("CSV must have columns: x, y, label"),
hr(),
h4("Instructions"),
p("Click on a label to select it, then click where you want to move it."),
hr(),
h4("Plot Limits"),
numericInput("x_min", "X Min:", value = 0, step = 0.5),
numericInput("x_max", "X Max:", value = 10, step = 0.5),
numericInput("y_min", "Y Min:", value = 0, step = 0.5),
numericInput("y_max", "Y Max:", value = 10, step = 0.5)
)
),
column(9,
plotOutput("ggplot",
width = "700px",
height = "500px",
click = "plot_click"),
hr(),
h3("Generated R Code"),
verbatimTextOutput("r_code"),
p("Copy and paste this code into your R script.")
)
)
)
# SERVER
server <- function(input, output, session) {
# Default data
default_data <- data.frame(
x = c(3, 6, 4.5, 7.5, 2),
y = c(4.5, 7.5, 3, 6, 8),
label = c("Point A", "Point B", "Point C", "Point D", "Point E"),
stringsAsFactors = FALSE
)
# Reactive data from file upload
uploaded_data <- reactive({
req(input$csv_file)
tryCatch({
df <- read.csv(input$csv_file$datapath, stringsAsFactors = FALSE)
# Check for required columns
if(!all(c("x", "y", "label") %in% names(df))) {
showNotification("CSV must contain columns: x, y, label", type = "error")
return(default_data)
}
# Convert to proper types
df$x <- as.numeric(df$x)
df$y <- as.numeric(df$y)
df$label <- as.character(df$label)
# Remove rows with NA values
df <- df[complete.cases(df[c("x", "y", "label")]), ]
if(nrow(df) == 0) {
showNotification("No valid data rows found", type = "error")
return(default_data)
}
showNotification(paste("Loaded", nrow(df), "data points"), type = "message")
return(df)
}, error = function(e) {
showNotification(paste("Error reading file:", e$message), type = "error")
return(default_data)
})
})
# Use uploaded data if available, otherwise use default
current_data <- reactive({
if(!is.null(input$csv_file)) {
uploaded_data()
} else {
default_data
}
})
# Reactive values
rv <- reactiveValues(
label_x = NULL,
label_y = NULL,
selected_label = NULL
)
# Initialize label positions when data changes
observe({
df <- current_data()
if(is.null(rv$label_x) || length(rv$label_x) != nrow(df)) {
rv$label_x <- df$x
rv$label_y <- df$y
}
})
# Auto-update axis limits when new data is loaded
observe({
df <- current_data()
if(!is.null(input$csv_file)) {
x_range <- range(df$x, na.rm = TRUE)
y_range <- range(df$y, na.rm = TRUE)
x_padding <- diff(x_range) * 0.1
y_padding <- diff(y_range) * 0.1
updateNumericInput(session, "x_min", value = floor(x_range[1] - x_padding))
updateNumericInput(session, "x_max", value = ceiling(x_range[2] + x_padding))
updateNumericInput(session, "y_min", value = floor(y_range[1] - y_padding))
updateNumericInput(session, "y_max", value = ceiling(y_range[2] + y_padding))
}
})
# Handle plot clicks
observeEvent(input$plot_click, {
click <- input$plot_click
if(is.null(rv$selected_label)) {
# Select the nearest label
distances <- sqrt((rv$label_x - click$x)^2 + (rv$label_y - click$y)^2)
if(min(distances) < 1.5) {
rv$selected_label <- which.min(distances)
}
} else {
# Move the selected label to click position
rv$label_x[rv$selected_label] <- click$x
rv$label_y[rv$selected_label] <- click$y
rv$selected_label <- NULL
}
})
# Render the ggplot
output$ggplot <- renderPlot({
df <- current_data()
req(rv$label_x)
# Create data frame for label positions
label_df <- data.frame(
x = rv$label_x,
y = rv$label_y,
label = df$label,
selected = sapply(1:nrow(df), function(i) {
!is.null(rv$selected_label) && i == rv$selected_label
})
)
# Create the plot
p <- ggplot(df, aes(x = x, y = y)) +
# Add connection lines from points to labels
geom_segment(
data = data.frame(
x = df$x,
y = df$y,
xend = rv$label_x,
yend = rv$label_y),
aes(x = x, y = y, xend = xend, yend = yend),
linetype = "dashed", color = "black") +
# Add data points
geom_point() +
# Add labels (showing final result)
geom_label(
data = label_df,
aes(x = x, y = y, label = label, fill = selected)) +
scale_fill_manual(values = c("FALSE" = "white", "TRUE" = "#dbeafe"),
guide = "none") +
# Styling
coord_cartesian(
xlim = c(input$x_min, input$x_max),
ylim = c(input$y_min, input$y_max),
clip = "off")
p
})
# Generate R code
output$r_code <- renderText({
df <- current_data()
req(rv$label_x)
labels <- paste0('"', df$label, '"', collapse = ", ")
x_coords <- paste0(" ", sprintf("%.2f", rv$label_x), collapse = ",\n")
y_coords <- paste0(" ", sprintf("%.2f", rv$label_y), collapse = ",\n")
sprintf('
library(ggplot2)
library(ggrepel)
# Your data
df <- data.frame(
x = c(%s),
y = c(%s),
label = c(%s)
)
# Custom label positions from dragging
label_positions <- data.frame(
x = c(
%s),
y = c(
%s))
# Create the plot
ggplot(df, aes(x = x, y = y)) +
geom_point() +
geom_label_repel(
aes(label = label),
nudge_x = label_positions$x - df$x,
nudge_y = label_positions$y - df$y) +
xlim(%s, %s) +
ylim(%s, %s)',
paste(sprintf("%.2f", df$x), collapse = ", "),
paste(sprintf("%.2f", df$y), collapse = ", "),
labels,
x_coords,
y_coords,
input$x_min,
input$x_max,
input$y_min,
input$y_max)
})
}
shinyApp(ui = ui, server = server)