Instantly share code, notes, and snippets.
Created
June 13, 2026 04:48
-
Star
0
(0)
You must be signed in to star a gist -
Fork
0
(0)
You must be signed in to fork a gist
-
-
Save AbodiDawoud/4a0bb47891da45cbf8c668e9b41f9991 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | |
| } |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Screen-Recording.mov