Skip to content

Instantly share code, notes, and snippets.

@AbodiDawoud
Created June 13, 2026 04:48
Show Gist options
  • Select an option

  • Save AbodiDawoud/4a0bb47891da45cbf8c668e9b41f9991 to your computer and use it in GitHub Desktop.

Select an option

Save AbodiDawoud/4a0bb47891da45cbf8c668e9b41f9991 to your computer and use it in GitHub Desktop.
import SwiftUI
struct QuietPopupMenu: View {
private let options = ["Focus", "Later", "Done"]
@Namespace private var plateNamespace
@State private var selectedIndex = 0
@State private var hoveredIndex: Int?
@State private var isOpen = false
@State private var isHovered = false
private var activeIndex: Int {
hoveredIndex ?? selectedIndex
}
var body: some View {
ZStack(alignment: .top) {
trigger
.zIndex(2)
if isOpen {
popup
.offset(y: 54)
.zIndex(1)
.transition(
.asymmetric(
insertion: .opacity
.combined(with: .scale(scale: 0.76, anchor: .top))
.combined(with: .offset(y: -12)),
removal: .opacity
.combined(with: .scale(scale: 0.92, anchor: .top))
.combined(with: .offset(y: -8))
)
)
}
}
.frame(width: 220, height: 174, alignment: .top)
.animation(.spring(response: 0.40, dampingFraction: 0.76), value: isOpen)
.animation(.spring(response: 0.28, dampingFraction: 0.78), value: isHovered)
.animation(.spring(response: 0.30, dampingFraction: 0.82), value: hoveredIndex)
.animation(.spring(response: 0.36, dampingFraction: 0.74), value: selectedIndex)
}
private var trigger: some View {
Button {
toggleMenu()
} label: {
ZStack {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(Color(white: 0.955))
.scaleEffect(x: isOpen ? 1.035 : 1, y: isOpen ? 0.97 : 1)
Text(isOpen ? "Select" : options[selectedIndex])
.font(.system(size: 15, weight: .semibold, design: .rounded))
.foregroundStyle(Color.primary.opacity(0.82))
.id(isOpen ? "select" : options[selectedIndex])
.transition(.blurReplace)
}
.frame(width: isOpen ? 146 : 132, height: 44)
}
.buttonStyle(QuietPopupPressStyle(isHovered: isHovered))
.onHover { hovered in
withAnimation(.spring(response: 0.24, dampingFraction: 0.78)) {
isHovered = hovered
}
}
}
private var popup: some View {
VStack(spacing: 4) {
ForEach(options.indices, id: \.self) { index in
QuietPopupRow(
title: options[index],
isActive: activeIndex == index,
isSelected: selectedIndex == index,
namespace: plateNamespace
) {
choose(index)
} onHover: {
withAnimation(.spring(response: 0.26, dampingFraction: 0.80)) {
hoveredIndex = index
}
}
.transition(.blurReplace.combined(with: .offset(y: -6)))
.animation(
.spring(response: 0.34, dampingFraction: 0.78)
.delay(Double(index) * 0.035),
value: isOpen
)
}
}
.padding(7)
.frame(width: 172)
.background(Color(white: 0.955), in: RoundedRectangle(cornerRadius: 18, style: .continuous))
.contentShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
.onHover { hovering in
guard !hovering else { return }
withAnimation(.spring(response: 0.28, dampingFraction: 0.84)) {
hoveredIndex = nil
}
}
}
private func toggleMenu() {
withAnimation(.spring(response: 0.38, dampingFraction: 0.72)) {
hoveredIndex = nil
isOpen.toggle()
}
}
private func choose(_ index: Int) {
withAnimation(.spring(response: 0.28, dampingFraction: 0.78)) {
selectedIndex = index
hoveredIndex = index
}
Task { @MainActor in
try? await Task.sleep(nanoseconds: 130_000_000)
withAnimation(.spring(response: 0.40, dampingFraction: 0.78)) {
isOpen = false
hoveredIndex = nil
}
}
}
}
private struct QuietPopupRow: View {
let title: String
let isActive: Bool
let isSelected: Bool
let namespace: Namespace.ID
let action: () -> Void
let onHover: () -> Void
var body: some View {
Button(action: action) {
ZStack {
if isActive {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color.white.opacity(isSelected ? 0.74 : 0.58))
.matchedGeometryEffect(id: "quiet-popup-plate", in: namespace)
.transition(.opacity)
}
Text(title)
.font(.system(size: 13, weight: isSelected ? .semibold : .medium, design: .rounded))
.foregroundStyle(Color.primary.opacity(isSelected ? 0.82 : 0.58))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12)
}
.frame(height: 30)
.contentShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
.buttonStyle(.plain)
.onHover { hovering in
if hovering {
onHover()
}
}
}
}
private struct QuietPopupPressStyle: ButtonStyle {
let isHovered: Bool
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.968 : isHovered ? 1.012 : 1)
.animation(.spring(response: 0.22, dampingFraction: 0.66), value: configuration.isPressed)
.animation(.spring(response: 0.26, dampingFraction: 0.78), value: isHovered)
}
}
#Preview {
QuietPopupMenu()
.padding(80)
.background(Color.white.opacity(0.95))
.preferredColorScheme(.light)
}
@AbodiDawoud

Copy link
Copy Markdown
Author
Screen-Recording.mov

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment