Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
de77f50
Quickstart examples
sfc-gh-dmatthews Dec 8, 2025
6723fcd
Create components examples
sfc-gh-dmatthews Dec 8, 2025
07a84c7
Update precommit linter
sfc-gh-dmatthews Dec 8, 2025
a17eda1
Re-format
sfc-gh-dmatthews Dec 8, 2025
1617d19
Radial menu
sfc-gh-dmatthews Dec 8, 2025
ef3168a
Danger button custom component
sfc-gh-dmatthews Dec 8, 2025
7f08d98
Stopwatch custom component
sfc-gh-dmatthews Dec 8, 2025
5932913
Radial dial example
sfc-gh-dmatthews Dec 9, 2025
445aadc
Add cleanup
sfc-gh-dmatthews Dec 17, 2025
980d26b
Rename and formatting
sfc-gh-dmatthews Dec 17, 2025
67856aa
Rename files
sfc-gh-dmatthews Dec 17, 2025
fe29a71
Theming tweaks
sfc-gh-dmatthews Dec 20, 2025
6fc8cc3
Closing animation for radial menu
sfc-gh-dmatthews Dec 20, 2025
89ca6f8
Component internal rename
sfc-gh-dmatthews Dec 20, 2025
5f40d0f
Increment after decrement
sfc-gh-dmatthews Dec 20, 2025
00fce8a
Stop danger button ctrl + click
sfc-gh-dmatthews Dec 20, 2025
79ad15b
Adjust radial menu overlay window resize
sfc-gh-dmatthews Dec 21, 2025
dc6936c
Allow arbitrary item count in ring
sfc-gh-dmatthews Dec 21, 2025
160ae6e
Simplify danger button
sfc-gh-dmatthews Dec 21, 2025
1842358
Revert "Update precommit linter"
sfc-gh-dmatthews Dec 21, 2025
e17c927
Undo lint change
sfc-gh-dmatthews Dec 22, 2025
a25de2e
Update danger button toast
sfc-gh-dmatthews Dec 23, 2025
c63a5b7
Fix radial menu scroll
sfc-gh-dmatthews Mar 7, 2026
916a7d3
Update radial menu example
sfc-gh-dmatthews Mar 9, 2026
8ce01f4
Update apps for consistency
sfc-gh-dmatthews Mar 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from pathlib import Path
import streamlit as st

component_dir = Path(__file__).parent


@st.cache_data
def load_component_code():
with open(component_dir / "button.css", "r") as f:
CSS = f.read()
with open(component_dir / "button.html", "r") as f:
HTML = f.read()
with open(component_dir / "button.js", "r") as f:
JS = f.read()
return HTML, CSS, JS


HTML, CSS, JS = load_component_code()

danger_button = st.components.v2.component(
name="hold_to_confirm",
html=HTML,
css=CSS,
js=JS,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
.hold-button {
position: relative;
width: 7.5rem;
height: 7.5rem;
padding: 0 2rem;
border-radius: 50%;
border: 1px solid var(--st-primary-color);
background: var(--st-secondary-background-color);
cursor: pointer;
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}

.hold-button:hover {
transform: scale(1.05);
border-color: var(--st-red-color);
}

.hold-button:active:not(:disabled) {
transform: scale(0.98);
}

.hold-button:disabled {
cursor: not-allowed;
opacity: 0.9;
}

.hold-button.holding {
animation: pulse 0.5s ease-in-out infinite;
border-color: var(--st-red-color);
}

.hold-button.triggered {
animation: success-burst 0.6s ease-out forwards;
}

@keyframes pulse {
0%,
100% {
box-shadow: 0 0 0 0 var(--st-red-color);
}
50% {
box-shadow: 0 0 0 15px transparent;
}
}

@keyframes success-burst {
0% {
transform: scale(1);
}
50% {
transform: scale(1.15);
background: var(--st-red-background-color);
}
100% {
transform: scale(1);
}
}

.progress-ring {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
transform: rotate(-90deg);
}

.ring-bg {
fill: none;
stroke: var(--st-border-color);
stroke-width: 4;
}

.ring-progress {
fill: none;
stroke: var(--st-red-color);
stroke-width: 4;
stroke-linecap: round;
stroke-dasharray: 283;
stroke-dashoffset: 283;
transition: stroke-dashoffset 0.1s linear;
filter: drop-shadow(0 0 0.5rem var(--st-red-color));
}

.button-content {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
font-family: var(--st-font);
}

.icon {
font-size: 2rem;
transition: transform 0.3s ease;
}

.hold-button:hover .icon {
transform: scale(1.1);
}

.hold-button.holding .icon {
animation: shake 0.15s ease-in-out infinite;
}

@keyframes shake {
0%,
100% {
transform: translateX(0);
}
25% {
transform: translateX(-2px) rotate(-5deg);
}
75% {
transform: translateX(2px) rotate(5deg);
}
}

.label {
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--st-text-color);
opacity: 0.6;
transition: all 0.3s ease;
}

.hold-button.holding .label {
color: var(--st-red-color);
opacity: 1;
}

.hold-button.triggered .icon,
.hold-button.triggered .label {
color: var(--st-primary-color);
opacity: 1;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<button id="danger-btn" class="hold-button">
<svg class="progress-ring" viewBox="0 0 100 100">
<circle class="ring-bg" cx="50" cy="50" r="45" />
<circle id="ring-progress" class="ring-progress" cx="50" cy="50" r="45" />
</svg>
<div class="button-content">
<span id="icon" class="icon">πŸ—‘οΈ</span>
<span id="label" class="label">Hold to Delete</span>
</div>
</button>
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
const HOLD_DURATION = 2000; // 2 seconds
const COOLDOWN_DURATION = 1500; // cooldown after trigger
const CIRCUMFERENCE = 2 * Math.PI * 45; // circle circumference

export default function ({ parentElement, setTriggerValue, data }) {
const button = parentElement.querySelector("#danger-btn");
const progress = parentElement.querySelector("#ring-progress");
const icon = parentElement.querySelector("#icon");
const label = parentElement.querySelector("#label");

let startTime = null;
let animationFrame = null;
let isDisabled = false; // Prevent interaction during cooldown

function updateProgress() {
if (!startTime) return;

const elapsed = Date.now() - startTime;
const progressPercent = Math.min(elapsed / HOLD_DURATION, 1);
const offset = CIRCUMFERENCE * (1 - progressPercent);

progress.style.strokeDashoffset = offset;

if (progressPercent >= 1) {
// Triggered!
triggerAction();
} else {
animationFrame = requestAnimationFrame(updateProgress);
}
}

function startHold() {
if (isDisabled) return; // Ignore if in cooldown

startTime = Date.now();
button.classList.add("holding");
label.textContent = data?.continue ?? "Keep holding...";
animationFrame = requestAnimationFrame(updateProgress);
}

function cancelHold() {
if (isDisabled) return; // Ignore if in cooldown

startTime = null;
button.classList.remove("holding");
label.textContent = data?.start ?? "Hold to Delete";
progress.style.strokeDashoffset = CIRCUMFERENCE;

if (animationFrame) {
cancelAnimationFrame(animationFrame);
animationFrame = null;
}
}

function triggerAction() {
cancelAnimationFrame(animationFrame);
animationFrame = null;
startTime = null;
isDisabled = true; // Disable during cooldown

button.classList.remove("holding");
button.classList.add("triggered");
button.disabled = true;

icon.textContent = "βœ“";
label.textContent = data?.completed ?? "Deleted!";
progress.style.strokeDashoffset = 0;

// Send trigger to Python
setTriggerValue("confirmed", true);

// Reset after cooldown
setTimeout(() => {
button.classList.remove("triggered");
button.disabled = false;
isDisabled = false;
icon.textContent = data?.icon ?? "πŸ—‘οΈ";
label.textContent = data?.start ?? "Hold to Delete";
progress.style.strokeDashoffset = CIRCUMFERENCE;
}, COOLDOWN_DURATION);
}

function handleTouchStart(e) {
e.preventDefault();
startHold();
}

// Mouse events
button.addEventListener("mousedown", startHold);
button.addEventListener("mouseup", cancelHold);
button.addEventListener("mouseleave", cancelHold);
button.addEventListener("contextmenu", cancelHold); // Ctrl+Click on Mac

// Touch events for mobile
button.addEventListener("touchstart", handleTouchStart);
button.addEventListener("touchend", cancelHold);
button.addEventListener("touchcancel", cancelHold);

return () => {
if (animationFrame) cancelAnimationFrame(animationFrame);

// Remove mouse event listeners
button.removeEventListener("mousedown", startHold);
button.removeEventListener("mouseup", cancelHold);
button.removeEventListener("mouseleave", cancelHold);
button.removeEventListener("contextmenu", cancelHold);

// Remove touch event listeners
button.removeEventListener("touchstart", handleTouchStart);
button.removeEventListener("touchend", cancelHold);
button.removeEventListener("touchcancel", cancelHold);
};
}
33 changes: 33 additions & 0 deletions python/concept-source/components-danger-button/streamlit_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import streamlit as st
from danger_button_component import danger_button

st.title("Hold-to-Confirm Button")
st.caption("A dangerous action that requires intentional confirmation")

# Track deletion events
if "deleted_items" not in st.session_state:
st.session_state.deleted_items = []


# Callback when deletion is confirmed
def on_delete_confirmed():
st.session_state.deleted_items.append(
f"Deleted item #{len(st.session_state.deleted_items) + 1}"
)
st.toast("Item permanently deleted!", icon="πŸ—‘οΈ")


# Render the component
with st.container(horizontal_alignment="center"):
result = danger_button(
key="danger_btn",
on_confirmed_change=on_delete_confirmed,
width="content"
)

# Show deletion history
if st.session_state.deleted_items:
st.divider()
st.subheader("Deletion Log")
for item in reversed(st.session_state.deleted_items[-3:]):
st.write(f"β€’ {item}")
9 changes: 9 additions & 0 deletions python/concept-source/components-hello-world.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import streamlit as st

hello_component = st.components.v2.component(
name="hello_world",
html="<h2>Hello, World!</h2>",
css="h2 { color: var(--st-primary-color); }",
)

hello_component()
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import streamlit as st
from pathlib import Path

# Get the current file's directory
_COMPONENT_DIR = Path(__file__).parent

@st.cache_data
def load_html():
with open(_COMPONENT_DIR / "component.html", "r") as f:
return f.read()

@st.cache_data
def load_css():
with open(_COMPONENT_DIR / "component.css", "r") as f:
return f.read()

@st.cache_data
def load_js():
with open(_COMPONENT_DIR / "component.js", "r") as f:
return f.read()

HTML = load_html()
CSS = load_css()
JS = load_js()
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
.counter {
padding: 2rem;
border: 1px solid var(--st-border-color);
border-radius: var(--st-base-radius);
font-family: var(--st-font);
text-align: center;
}

.buttons {
margin-top: 1rem;
}

button {
margin: 0 0.5rem;
padding: 0.5rem 1rem;
background: var(--st-primary-color);
color: white;
border: none;
border-radius: var(--st-button-radius);
cursor: pointer;
}

button:hover {
opacity: 0.8;
}

#reset {
background: var(--st-red-color);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<div class="counter">
<h3>Count: <span id="display">0</span></h3>
<div class="buttons">
<button id="decrement">-1</button>
<button id="increment">+1</button>
<button id="reset">Reset</button>
</div>
</div>
Loading