Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions app/components/ProjectsSection.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ const cardStates = reactive<Record<string, CardState>>(
Object.fromEntries(resume.projects.map((p) => [p.name, { x: 50, y: 50, hover: false }])),
)

const { registerCard } = useScrollActivatedCards(cardStates)

const onCardMove = (name: string, e: MouseEvent) => {
const el = e.currentTarget as HTMLElement
const rect = el.getBoundingClientRect()
Expand Down Expand Up @@ -41,6 +43,7 @@ const onFaviconError = (e: Event) => {
<li v-for="project in resume.projects" :key="project.name">
<component
:is="project.href ? 'a' : 'div'"
:ref="(el: Element | ComponentPublicInstance | null) => registerCard(el, project.name)"
:href="project.href"
:target="project.href ? '_blank' : undefined"
:rel="project.href ? 'noopener noreferrer' : undefined"
Expand Down
3 changes: 3 additions & 0 deletions app/components/SkillsSection.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ const cardStates = reactive<Record<string, CardState>>(
Object.fromEntries(resume.skills.map((g) => [g.label, { x: 50, y: 50, hover: false }])),
)

const { registerCard } = useScrollActivatedCards(cardStates)

const onCardMove = (label: string, e: MouseEvent) => {
const el = e.currentTarget as HTMLElement
const rect = el.getBoundingClientRect()
Expand All @@ -26,6 +28,7 @@ const onCardLeave = (label: string) => {
<div
v-for="group in resume.skills"
:key="group.label"
:ref="(el: Element | ComponentPublicInstance | null) => registerCard(el, group.label)"
class="skill-card relative overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-bg-elevated)] p-5 transition-colors duration-200 hover:border-[var(--color-fg-subtle)]"
:style="{
'--mx': cardStates[group.label]?.x + '%',
Expand Down
53 changes: 53 additions & 0 deletions app/composables/useScrollActivatedCards.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
type CardState = { x: number; y: number; hover: boolean }

/**
* Drives card hover states via IntersectionObserver on touch devices.
* On pointer-fine devices (desktop) it does nothing — mouse events handle it.
*
* Usage:
* const { registerCard } = useScrollActivatedCards(cardStates)
* <div :ref="(el) => registerCard(el, key)" …>
*/
export function useScrollActivatedCards(cardStates: Record<string, CardState>) {
const pending = new Map<string, Element>()
const observers: IntersectionObserver[] = []

const registerCard = (el: Element | unknown, key: string) => {
if (el instanceof Element) pending.set(key, el)
}

onMounted(() => {
if (!window.matchMedia('(pointer: coarse)').matches) return

for (const [key, el] of pending) {
const state = cardStates[key]
if (!state) continue

const observer = new IntersectionObserver(
([entry]) => {
if (!entry) return
state.hover = entry.isIntersecting
if (entry.isIntersecting) {
state.x = 50
state.y = 50
}
},
{
// Shrink the effective viewport by 25% from the bottom so cards only
// activate once they've scrolled well clear of the bottom edge.
rootMargin: '0px 0px -25% 0px',
threshold: 0.1,
},
)

observer.observe(el)
observers.push(observer)
}
})

onBeforeUnmount(() => {
observers.forEach((o) => o.disconnect())
})

return { registerCard }
}
Loading