Skip to content

feat(header, footer, tab-bar): add scrollEffect prop#31263

Open
OS-susmitabhowmik wants to merge 15 commits into
nextfrom
ROU-12870-scroll-effect
Open

feat(header, footer, tab-bar): add scrollEffect prop#31263
OS-susmitabhowmik wants to merge 15 commits into
nextfrom
ROU-12870-scroll-effect

Conversation

@OS-susmitabhowmik

@OS-susmitabhowmik OS-susmitabhowmik commented Jul 10, 2026

Copy link
Copy Markdown

Issue number: resolves internal


What is the current behavior?

  • ion-header and ion-footer have a collapse prop for scroll-driven effects (condense, fade), but it only works on the iOS theme.
  • ion-tab-bar has an unreleased hideOnScroll boolean prop that only works on the Ionic theme with expand="compact".

What is the new behavior?

  • Adds a new scrollEffect string enum prop to ion-header, ion-footer, and ion-tab-bar.
  • scrollEffect="hide" slides the bar out of view when scrolling down and back in when scrolling up.
  • scrollEffect="condense" and scrollEffect="fade" on ion-header/ion-footer replace the existing collapse values and work across all themes.
  • Deprecates the collapse prop on ion-header and ion-footer with a console warning directing developers to use scrollEffect.
  • Removes the unreleased hideOnScroll prop from ion-tab-bar.
  • When ion-tab-bar is nested inside ion-footer, the footer owns the hide animation to prevent double-animation

Does this introduce a breaking change?

  • Yes
  • No

Other information

Relevant Test Pages:

Header

Footer

Tab Bar

@vercel

vercel Bot commented Jul 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
ionic-framework Ready Ready Preview, Comment Jul 13, 2026 6:47pm

Request Review

@github-actions github-actions Bot added package: core @ionic/core package package: angular @ionic/angular package package: vue @ionic/vue package package: react @ionic/react package labels Jul 10, 2026
@OS-susmitabhowmik OS-susmitabhowmik marked this pull request as ready for review July 13, 2026 17:01
@OS-susmitabhowmik OS-susmitabhowmik requested a review from a team as a code owner July 13, 2026 17:01
@OS-susmitabhowmik OS-susmitabhowmik requested a review from ShaneK July 13, 2026 17:01

@ShaneK ShaneK left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey, great work on this! I highlighted a few issues I found, some of them aren't as important as others, but still

const contentEl = pageEl ? findIonContent(pageEl) : null;
// condense/fade via the deprecated `collapse` prop are iOS-only.
// condense/fade via the new `scrollEffect` prop work in all themes.
const isModeRestricted = scrollEffect === undefined && getIonTheme(this) !== 'ios';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstrings and PR say condense/fade work across all themes now, but only the TS gate got unlocked here. All the condense/fade styling still lives in header.ios.scss, and .header-md.header-collapse-condense { display: none } still hides the large-title header on md, so <ion-header scroll-effect="condense"> renders nothing there. fade doesn't reach the toolbar background on md/ionic either, since the --opacity-scale: inherit bridge is iOS-only. Only hide has cross-theme e2e coverage, which is why this slipped through. I think we either port the condense/fade styling to md/ionic (with e2e), or scope the "all themes" claim down to hide and keep condense/fade documented as iOS-only. Which way do you want to go?

const ION_CONTENT_TAG_NAME = 'ION-CONTENT';
export const ION_CONTENT_ELEMENT_SELECTOR = 'ion-content';
export const ION_CONTENT_CLASS_SELECTOR = '.ion-content-scroll-host';
export const ION_PAGE_ELEMENT_SELECTOR = 'ion-app,ion-page,.ion-page,page-inner';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This hardcodes ion-app, but the header/footer code it replaces used config.get('appRootSelector', 'ion-app'). ion-content still resolves its page via that config (content.tsx#L539), so an app with a custom appRootSelector and no ion-page ancestor could land the header/footer and content on different page elements. The blast radius is narrow, but it's a silent drop of config support and it touches the existing collapse paths too, not just hide. Was that intentional? If not, I think we should compose the selector from the config value so content and header stay in sync.

}
};

private setupScrollEffectHide = async (contentEl: HTMLElement) => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setupScrollEffectHide awaits getScrollElement before assigning this.scrollHideCtrl/this.resizeObserver, and it runs from componentDidUpdate, which fires on every keyboardVisible toggle. If two setups overlap (mostly at mount), the first controller and ResizeObserver get orphaned since teardown only tracks the latest reference, and it rebuilds the whole controller on every re-render regardless. Could we guard this with the promise-identity pattern already used for keyboardCtrlPromise in this file? Same thing applies to setupScrollEffectHide in ion-header.

}

const condenseHeader = contentEl.querySelector('ion-header[collapse="condense"]') as HTMLElement | null;
const condenseHeader = contentEl.querySelector(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One thing to watch on the fade + condense pairing: setToolbarBackgroundOpacity early-returns on headerEl.collapse === 'fade' so the condense handler doesn't fight handleHeaderFade over --opacity-scale. That guard reads only collapse, which is undefined when the dev uses the new scrollEffect="fade", so a header migrated from collapse to scrollEffect loses the guard and gets both functions writing --opacity-scale. I think that check needs the same scrollEffect ?? collapse resolution used everywhere else, e.g. const effect = headerEl.scrollEffect ?? headerEl.collapse; if (effect === 'fade') return;.

if (theme !== 'ionic' || !this.hideOnScroll || this.expand !== 'compact') {
private setupScrollEffect = async () => {
// When nested inside ion-footer, the footer owns the hide animation.
if (this.el.closest('ion-footer')) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This bails for any ion-footer, but the double-animation only happens when the footer itself hides. A tab-bar in a plain footer with scroll-effect="hide" gets a silent no-op, and tab-bar-in-footer is a pretty common layout. Could we defer only when the footer resolves to hide (closest('ion-footer')?.scrollEffect === 'hide'), and/or printIonWarning when we drop the prop so it isn't silent?

const { scrollEffect, collapse } = this;

if (collapse !== undefined && scrollEffect === undefined) {
printIonWarning(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This warning runs from checkCollapsibleFooter, which fires on both componentDidLoad and componentDidUpdate, and printIonWarning doesn't dedupe. The footer re-renders on every keyboard toggle (keyboardVisible), so a footer using collapse will spam this to the console. I think we should fire it once. While we're here, the other deprecation warnings lead with a bracketed tag and pass the element, e.g. [ion-col] - / [ion-picker-legacy] -, so worth matching: printIonWarning('[ion-footer] - The \collapse` property is deprecated. Use `scrollEffect` instead.', this.el). Same applies to ion-header`.

const visibleZone = 80;
// Hides after 60px of continuous downward scrolling only, when scrolling up threashold should be 0px
const scrollThresholdHide = 60;
private setHidden(hidden: boolean) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setHidden toggles tab-bar-scroll-hidden, inert, and aria-hidden imperatively, but render() already derives all three from the @State isHidden, so this looks redundant. Also worth noting ion-header/ion-footer implement the same hide feature with a plain imperative field instead of @State + render, so the feature ships two different ways across the three components. Could these converge on the declarative @State approach?

return;
}

const isScrollingDown = currentScrollTop > lastScrollPosition;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the direction-change hysteresis here (the scrollPositionAtDirectionChange anchor plus SCROLL_HIDE_THRESHOLD travel) is the core of this controller and the easiest bit to misread, but it's uncommented while smaller mechanics nearby have notes. A short comment on the anchor, plus a line on the module doc about why both wheel and scroll are handled, would help the next reader. Up to you though!

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

Labels

package: angular @ionic/angular package package: core @ionic/core package package: vue @ionic/vue package

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants